first commit
|
|
@ -0,0 +1,100 @@
|
|||
import { injectApp, waitLogin, gatewayHttpClient } from '@jdmini/api'
|
||||
const storage = require('./utils/storage.js')
|
||||
|
||||
App(injectApp()({
|
||||
globalData: {
|
||||
userSettings: null
|
||||
},
|
||||
async onLaunch() {
|
||||
if (wx.canIUse('getUpdateManager')) {
|
||||
const updateManager = wx.getUpdateManager();
|
||||
updateManager.onCheckForUpdate(function (res) {
|
||||
if (res.hasUpdate) {
|
||||
updateManager.onUpdateReady(function () {
|
||||
wx.showModal({
|
||||
title: '更新提示',
|
||||
content: '新版本已经准备好,是否重启应用?',
|
||||
success(res) {
|
||||
if (res.confirm) {
|
||||
updateManager.applyUpdate();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
// 等待登录完成
|
||||
await waitLogin()
|
||||
// 初始化用户设置(计算学习天数)
|
||||
this.globalData.userSettings = storage.initSettings()
|
||||
},
|
||||
|
||||
// 内容安全
|
||||
async checkdata(txt = '', checkType, mediaUrl = '') {
|
||||
try {
|
||||
if (!checkType || (checkType !== 2 && checkType !== 3)) {
|
||||
throw new Error('checkType必须为2(图片检测)或3(文本检测)')
|
||||
}
|
||||
if (checkType === 3 && !txt) {
|
||||
throw new Error('文本检测时content不能为空')
|
||||
}
|
||||
if (checkType === 2 && !mediaUrl) {
|
||||
throw new Error('图片检测时mediaUrl不能为空')
|
||||
}
|
||||
const postdata = { content: txt, checkType: checkType, mediaUrl: mediaUrl }
|
||||
const data = await gatewayHttpClient.request('/wx/v1/api/app/content/check', 'post', postdata)
|
||||
if (data.code === 200) {
|
||||
if (checkType == 3) return data.data.suggest === 'pass' ? 1 : 2
|
||||
if (checkType == 2) return data.data.id || null
|
||||
} else {
|
||||
return 2
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('checkdata error:', error)
|
||||
return 2
|
||||
}
|
||||
},
|
||||
|
||||
async checkimage(imgurl) {
|
||||
wx.showLoading({ title: '正在检查图片...', mask: true });
|
||||
try {
|
||||
const upfileData = await gatewayHttpClient.uploadFile(imgurl, 'image');
|
||||
const checkid = await this.checkdata('', 2, upfileData.data.url)
|
||||
let retryCount = 0;
|
||||
const maxRetries = 5;
|
||||
while (retryCount < maxRetries) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
const passcode = await this.checkSafetyResults(checkid);
|
||||
switch (passcode) {
|
||||
case 100:
|
||||
wx.hideLoading();
|
||||
return upfileData.data;
|
||||
case 20001:
|
||||
case 20002:
|
||||
case 20006:
|
||||
case 21000:
|
||||
wx.hideLoading();
|
||||
await gatewayHttpClient.deleteFile(upfileData.data.id);
|
||||
this.showwarning('图片含有违规内容,请重新选择');
|
||||
return null;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
retryCount++;
|
||||
}
|
||||
wx.hideLoading();
|
||||
await gatewayHttpClient.deleteFile(upfileData.data.id);
|
||||
wx.showToast({ title: '图片检查超时,请重试', icon: 'none' });
|
||||
return null;
|
||||
} catch (error) {
|
||||
wx.hideLoading();
|
||||
wx.showToast({ title: '检查失败,请重试', icon: 'none' });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
showwarning(txt) {
|
||||
wx.showModal({ title: '提示', content: txt, showCancel: false });
|
||||
}
|
||||
}))
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"pages": [
|
||||
"pages/home/home",
|
||||
"pages/category/category",
|
||||
"pages/course-detail/course-detail",
|
||||
"pages/study-step/study-step",
|
||||
"pages/practice/practice",
|
||||
"pages/work-submit/work-submit",
|
||||
"pages/profile/profile"
|
||||
],
|
||||
"window": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "画画怎么画",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"backgroundColor": "#f7f8fc"
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "#999999",
|
||||
"selectedColor": "#6C8CFF",
|
||||
"backgroundColor": "#ffffff",
|
||||
"borderStyle": "white",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/home/home",
|
||||
"text": "首页",
|
||||
"iconPath": "images/tabbar/home.png",
|
||||
"selectedIconPath": "images/tabbar/home_active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/practice/practice",
|
||||
"text": "练习",
|
||||
"iconPath": "images/tabbar/practice.png",
|
||||
"selectedIconPath": "images/tabbar/practice_active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/profile/profile",
|
||||
"text": "我的",
|
||||
"iconPath": "images/tabbar/profile.png",
|
||||
"selectedIconPath": "images/tabbar/profile_active.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
"style": "v2",
|
||||
"componentFramework": "glass-easel",
|
||||
"sitemapLocation": "sitemap.json",
|
||||
"lazyCodeLoading": "requiredComponents"
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/* 全局样式 */
|
||||
page {
|
||||
background-color: #f7f8fc;
|
||||
font-family: -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
color: #333333;
|
||||
font-size: 28rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 公共容器 */
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
padding-bottom: 40rpx;
|
||||
}
|
||||
|
||||
/* 卡片通用样式 */
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 主色 */
|
||||
.text-primary { color: #6C8CFF; }
|
||||
.text-orange { color: #FFB84D; }
|
||||
.text-gray { color: #999999; }
|
||||
.text-dark { color: #333333; }
|
||||
|
||||
/* 徽章/标签 */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 20rpx;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.badge-blue { background: #EEF1FF; color: #6C8CFF; }
|
||||
.badge-orange { background: #FFF4E5; color: #FFB84D; }
|
||||
.badge-green { background: #E8FAF0; color: #3CB371; }
|
||||
.badge-gray { background: #F2F2F2; color: #999999; }
|
||||
|
||||
/* 通用按钮 */
|
||||
.btn-primary {
|
||||
background: #6C8CFF;
|
||||
color: #ffffff;
|
||||
border-radius: 50rpx;
|
||||
text-align: center;
|
||||
font-size: 32rpx;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
padding: 24rpx 0;
|
||||
}
|
||||
.btn-primary::after { border: none; }
|
||||
|
||||
.btn-outline {
|
||||
background: #ffffff;
|
||||
color: #6C8CFF;
|
||||
border: 2rpx solid #6C8CFF;
|
||||
border-radius: 50rpx;
|
||||
text-align: center;
|
||||
font-size: 32rpx;
|
||||
font-weight: 500;
|
||||
padding: 24rpx 0;
|
||||
}
|
||||
.btn-outline::after { border: none; }
|
||||
|
||||
/* 分割线 */
|
||||
.divider {
|
||||
height: 1rpx;
|
||||
background: #F0F0F0;
|
||||
margin: 0 32rpx;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80rpx 40rpx;
|
||||
color: #BBBBBB;
|
||||
}
|
||||
.empty-state .empty-icon {
|
||||
font-size: 80rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.empty-state .empty-text {
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
/* 安全区底部 */
|
||||
.safe-bottom {
|
||||
height: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
After Width: | Height: | Size: 880 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 81 81" fill="none">
|
||||
<path d="M40.5 12L14 34H21V64H37V50H44V64H60V34H67L40.5 12Z" stroke="black" stroke-width="3.5" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 203 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1009 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 81 81" fill="none">
|
||||
<path d="M40.5 12L14 34H21V64H37V50H44V64H60V34H67L40.5 12Z" stroke="#6C8CFF" stroke-width="3.5" stroke-linejoin="round" fill="#6C8CFF" fill-opacity="0.15"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 240 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 606 B |
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 81 81" fill="none">
|
||||
<rect x="18" y="18" width="45" height="45" rx="6" stroke="black" stroke-width="3.5"/>
|
||||
<line x1="28" y1="32" x2="53" y2="32" stroke="black" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="28" y1="41" x2="53" y2="41" stroke="black" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="28" y1="50" x2="43" y2="50" stroke="black" stroke-width="3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 459 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 761 B |
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 81 81" fill="none">
|
||||
<rect x="18" y="18" width="45" height="45" rx="6" stroke="#6C8CFF" stroke-width="3.5" fill="#6C8CFF" fill-opacity="0.15"/>
|
||||
<line x1="28" y1="32" x2="53" y2="32" stroke="#6C8CFF" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="28" y1="41" x2="53" y2="41" stroke="#6C8CFF" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="28" y1="50" x2="43" y2="50" stroke="#6C8CFF" stroke-width="3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 502 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 81 81" fill="none">
|
||||
<circle cx="40.5" cy="30" r="12" stroke="black" stroke-width="3.5"/>
|
||||
<path d="M16 66c0-13.255 10.745-24 24.5-24S65 52.745 65 66" stroke="black" stroke-width="3.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 272 B |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 81 81" fill="none">
|
||||
<circle cx="40.5" cy="30" r="12" stroke="#6C8CFF" stroke-width="3.5" fill="#6C8CFF" fill-opacity="0.15"/>
|
||||
<path d="M16 66c0-13.255 10.745-24 24.5-24S65 52.745 65 66" stroke="#6C8CFF" stroke-width="3.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 311 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
|
@ -0,0 +1,295 @@
|
|||
// Generated by dts-bundle v0.7.3
|
||||
|
||||
declare module '@jdmini/api' {
|
||||
import { onLoginReady, waitLogin } from '@jdmini/api/app';
|
||||
import HttpClient, { gatewayHttpClient, baseHttpClient, apiHttpClient } from '@jdmini/api/httpClient';
|
||||
import { injectApp, injectPage, injectComponent, hijackApp, hijackAllPage } from '@jdmini/api/injector';
|
||||
import adManager from '@jdmini/api/adManager';
|
||||
export { onLoginReady, waitLogin, injectApp, injectPage, injectComponent, hijackApp, hijackAllPage, gatewayHttpClient, baseHttpClient, apiHttpClient, HttpClient, adManager, };
|
||||
}
|
||||
|
||||
declare module '@jdmini/api/app' {
|
||||
export interface AppOptions {
|
||||
gatewayUrl?: string;
|
||||
baseUrl?: string;
|
||||
apiUrl?: string;
|
||||
}
|
||||
export interface PageOptions {
|
||||
showInterstitialAd?: boolean;
|
||||
}
|
||||
export function initApp(options?: AppOptions): Promise<void>;
|
||||
export function showPage(options: PageOptions | undefined, pageId: string): Promise<void>;
|
||||
export const checkTokenValid: () => boolean;
|
||||
/**
|
||||
* 确保登录完成
|
||||
* @param {Function} callback - 回调函数
|
||||
* @returns {void}
|
||||
*/
|
||||
export function onLoginReady(callback: (...args: any[]) => void): void;
|
||||
/**
|
||||
* 等待登录完成
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function waitLogin(): Promise<void>;
|
||||
export function login(): Promise<void>;
|
||||
export function fetchEchoData(): Promise<void>;
|
||||
export function trackVisit(): Promise<void>;
|
||||
}
|
||||
|
||||
declare module '@jdmini/api/httpClient' {
|
||||
import { HttpClientOptions, RequestOptions, ApiResponse } from '@jdmini/api/types';
|
||||
class HttpClient {
|
||||
constructor({ baseURL, timeout }: HttpClientOptions);
|
||||
setBaseURL(baseURL: string): void;
|
||||
/**
|
||||
* 请求
|
||||
* @param {string} path 路径
|
||||
* @param {string} method 方法, 默认GET
|
||||
* @param {Object} data 数据, 默认{}
|
||||
* @param {Object} options 传入wx.request的其他配置, 默认{}
|
||||
* @returns {Promise<Object>} 返回一个Promise对象
|
||||
*/
|
||||
request<T = any>(path: string, method?: WechatMiniprogram.RequestOption['method'], data?: Record<string, any>, options?: RequestOptions): Promise<ApiResponse<T>>;
|
||||
/**
|
||||
* 上传文件
|
||||
* @param {string} filePath 文件路径
|
||||
* @param {Object} data 数据, 默认{}
|
||||
* @param {'avatar' | 'file'} type 类型, 默认'file'
|
||||
* @returns {Promise<Object>} 返回一个Promise对象
|
||||
*/
|
||||
uploadFile<T = any>(filePath: string, data?: Record<string, any>, type?: 'avatar' | 'file'): Promise<ApiResponse<T>>;
|
||||
/**
|
||||
* 上传文件
|
||||
* @param {string} filePath 文件路径
|
||||
* @param {Object} data 数据, 默认{}
|
||||
* @param {'avatar' | 'file'} type 类型, 默认'file'
|
||||
* @returns {Promise<Object>} 返回一个Promise对象
|
||||
*/
|
||||
upload<T = any>(path: string, filePath: string, data?: Record<string, any>): Promise<ApiResponse<T>>;
|
||||
/**
|
||||
* 删除文件
|
||||
* @param {number} fileId 文件id
|
||||
* @returns {Promise<Object>} 返回一个Promise对象
|
||||
*/
|
||||
deleteFile(fileId: number): Promise<ApiResponse<null>>;
|
||||
/**
|
||||
* 上传头像
|
||||
* @param {string} filePath 文件路径
|
||||
* @returns {Promise<Object>} 返回一个Promise对象
|
||||
*/
|
||||
uploadAvatar<T = any>(filePath: string): Promise<ApiResponse<T>>;
|
||||
}
|
||||
export const gatewayHttpClient: HttpClient;
|
||||
export const baseHttpClient: HttpClient;
|
||||
export const apiHttpClient: HttpClient;
|
||||
export default HttpClient;
|
||||
}
|
||||
|
||||
declare module '@jdmini/api/injector' {
|
||||
interface AppConfig {
|
||||
onLaunch?: (...args: any[]) => void | Promise<void>;
|
||||
[key: string]: any;
|
||||
}
|
||||
interface PageConfig {
|
||||
onShow?: (...args: any[]) => void | Promise<void>;
|
||||
[key: string]: any;
|
||||
}
|
||||
interface ComponentConfig {
|
||||
methods?: {
|
||||
onLoad?: (...args: any[]) => void | Promise<void>;
|
||||
onShow?: (...args: any[]) => void | Promise<void>;
|
||||
[key: string]: any;
|
||||
};
|
||||
[key: string]: any;
|
||||
}
|
||||
interface InjectAppOptions {
|
||||
gatewayUrl?: string;
|
||||
baseUrl?: string;
|
||||
apiUrl?: string;
|
||||
}
|
||||
interface InjectPageOptions {
|
||||
showInterstitialAd?: boolean;
|
||||
}
|
||||
/**
|
||||
* 注入应用配置
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {string} [options.gatewayUrl] - 网关地址,默认使用CONFIG.API.GATEWAY_URL
|
||||
* @param {string} [options.baseUrl] - 基础地址,默认使用CONFIG.API.BASE_URL
|
||||
* @param {string} [options.apiUrl] - api地址,默认使用CONFIG.API.API_URL
|
||||
* @returns {Function} 返回一个接收应用配置的函数
|
||||
*/
|
||||
export function injectApp(options?: InjectAppOptions): (appConfig: AppConfig) => AppConfig;
|
||||
/**
|
||||
* 注入页面配置
|
||||
* @param {InjectPageOptions} options - 配置选项
|
||||
* @param {boolean} [options.showInterstitialAd] - 是否在onShow显示插屏广告,默认不显示
|
||||
* @returns {Function} 返回一个接收页面配置的函数
|
||||
*/
|
||||
export function injectPage(options?: InjectPageOptions): (pageConfig?: PageConfig) => PageConfig;
|
||||
/**
|
||||
* 注入组件配置
|
||||
* @param {InjectPageOptions} options - 配置选项
|
||||
* @param {boolean} [options.showInterstitialAd] - 是否在onShow显示插屏广告,默认不显示
|
||||
* @returns {Function} 返回一个接收组件配置的函数
|
||||
*/
|
||||
export function injectComponent(options?: InjectPageOptions): (pageConfig?: PageConfig) => ComponentConfig;
|
||||
/**
|
||||
* 劫持App
|
||||
* @param {InjectAppOptions} options - 配置选项
|
||||
* @param {string} [options.gatewayUrl] - 网关地址,默认使用CONFIG.API.GATEWAY_URL
|
||||
* @param {string} [options.baseUrl] - 基础地址,默认使用CONFIG.API.BASE_URL
|
||||
* @param {string} [options.apiUrl] - api地址,默认使用CONFIG.API.API_URL
|
||||
* @returns {void}
|
||||
*/
|
||||
export const hijackApp: (options?: InjectAppOptions) => void;
|
||||
/**
|
||||
* 劫持所有Page
|
||||
* @param {InjectPageOptions} options - 配置选项
|
||||
* @param {boolean} [options.showInterstitialAd] - 是否在onShow显示插屏广告,默认不显示
|
||||
* @returns {void}
|
||||
*/
|
||||
export const hijackAllPage: (options?: InjectPageOptions) => void;
|
||||
export {};
|
||||
}
|
||||
|
||||
declare module '@jdmini/api/adManager' {
|
||||
import { AdData, LinkData, TopData } from '@jdmini/api/types';
|
||||
type Ads = Partial<Record<AdData['appPage'], AdData['ads'][0]['adUnitId']>>;
|
||||
class AdManager {
|
||||
/**
|
||||
* 广告数据
|
||||
*/
|
||||
ads: Ads;
|
||||
/**
|
||||
* 友情链接数据
|
||||
*/
|
||||
link: LinkData[];
|
||||
/**
|
||||
* 友情链接顶部广告数据
|
||||
*/
|
||||
top: TopData | null;
|
||||
constructor();
|
||||
/**
|
||||
* 确保广告数据就绪
|
||||
* @param {Function} callback - 回调函数
|
||||
* @returns {void}
|
||||
*/
|
||||
onDataReady: (callback: (...args: any[]) => void) => void;
|
||||
/**
|
||||
* 等待广告数据加载完成
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
waitAdData: () => Promise<void>;
|
||||
/**
|
||||
* 初始化广告数据
|
||||
* @returns {void}
|
||||
*/
|
||||
init: () => void;
|
||||
/**
|
||||
* 创建并展示插屏广告
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
createAndShowInterstitialAd: () => Promise<void>;
|
||||
/**
|
||||
* 创建并展示激励视频广告
|
||||
* @param {any} context - 页面上下文
|
||||
* @param {string} [pageId] - 页面ID
|
||||
* @returns {Promise<boolean>} 是否完成播放
|
||||
*/
|
||||
createAndShowRewardedVideoAd: (context: any, pageId?: string) => Promise<boolean>;
|
||||
}
|
||||
const _default: AdManager;
|
||||
export default _default;
|
||||
}
|
||||
|
||||
declare module '@jdmini/api/types' {
|
||||
export interface Config {
|
||||
API: {
|
||||
GATEWAY_URL: string;
|
||||
BASE_URL: string;
|
||||
API_URL: string;
|
||||
};
|
||||
APP: {
|
||||
APP_ID: number;
|
||||
LOGIN_MAX_RETRY: number;
|
||||
};
|
||||
HTTP: {
|
||||
TIMEOUT: number;
|
||||
};
|
||||
DATA: {
|
||||
PAGE_ID: string;
|
||||
};
|
||||
STORAGE_KEYS: {
|
||||
TOKEN: string;
|
||||
USER_INFO: string;
|
||||
SPA_DATA: string;
|
||||
LINK_DATA: string;
|
||||
TOP_DATA: string;
|
||||
};
|
||||
EVENT_KEYS: {
|
||||
LOGIN_SUCCESS: string;
|
||||
AD_DATA_READY: string;
|
||||
REWARDED_VIDEO_AD_CLOSE: string;
|
||||
};
|
||||
}
|
||||
export interface HttpClientOptions {
|
||||
baseURL: string;
|
||||
timeout?: number;
|
||||
}
|
||||
export interface RequestOptions {
|
||||
headers?: Record<string, string>;
|
||||
[key: string]: any;
|
||||
}
|
||||
export interface LoginData {
|
||||
appId: number;
|
||||
code: string;
|
||||
brand: string;
|
||||
model: string;
|
||||
platform: string;
|
||||
}
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
message?: string;
|
||||
data?: T;
|
||||
}
|
||||
export interface UserInfo {
|
||||
token: string;
|
||||
user: {
|
||||
id: number;
|
||||
name: string;
|
||||
avatar: string;
|
||||
openId: string;
|
||||
};
|
||||
}
|
||||
export interface EchoData {
|
||||
isPublished: boolean;
|
||||
spads: AdData[];
|
||||
links: LinkData[];
|
||||
top: TopData;
|
||||
version: number;
|
||||
}
|
||||
export interface AdData {
|
||||
id: number;
|
||||
status: number;
|
||||
appPage: 'banner' | 'custom' | 'video' | 'interstitial' | 'rewarded';
|
||||
ads: {
|
||||
id: number;
|
||||
type: number;
|
||||
adId: number;
|
||||
adUnitId: string;
|
||||
}[];
|
||||
}
|
||||
export interface LinkData {
|
||||
appId: string;
|
||||
appLogo: string;
|
||||
linkName: string;
|
||||
linkPage: string;
|
||||
}
|
||||
export interface TopData {
|
||||
appId: string;
|
||||
appLogo: string;
|
||||
linkName: string;
|
||||
appDsc: string;
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
|
@ -0,0 +1,22 @@
|
|||
import { adManager } from '@jdmini/api'
|
||||
|
||||
Component({
|
||||
properties: {
|
||||
type: {
|
||||
type: String,
|
||||
value: 'custom' // 可选banner, video, custom
|
||||
}
|
||||
},
|
||||
data: {
|
||||
ads: {}
|
||||
},
|
||||
lifetimes: {
|
||||
attached: function () {
|
||||
adManager.onDataReady(() => {
|
||||
this.setData({ ads: adManager.ads })
|
||||
})
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"component": true
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<view class="jdwx-ad-component">
|
||||
<ad wx:if="{{type === 'banner' && ads.banner}}" class="jdwx-ad-item" unit-id="{{ads.banner}}"></ad>
|
||||
<ad wx:if="{{type === 'video' && ads.video}}" class="jdwx-ad-item" ad-type="video" unit-id="{{ads.video}}"></ad>
|
||||
<ad-custom wx:if="{{type === 'custom' && ads.custom}}" class="jdwx-ad-item" unit-id="{{ads.custom}}"></ad-custom>
|
||||
</view>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
.jdwx-ad-component {
|
||||
padding: 10rpx;
|
||||
}
|
||||
|
||||
.jdwx-ad-item {
|
||||
bottom: 10rpx;
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { adManager } from '@jdmini/api'
|
||||
|
||||
Component({
|
||||
properties: {
|
||||
},
|
||||
data: {
|
||||
link: [],
|
||||
top: {},
|
||||
},
|
||||
pageLifetimes: {
|
||||
show: function () {
|
||||
adManager.onDataReady(() => {
|
||||
this.setData({
|
||||
link: adManager.link,
|
||||
top: adManager.top
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
gotopLink: function () {
|
||||
wx.vibrateShort()
|
||||
wx.openEmbeddedMiniProgram({
|
||||
appId: this.data.top.appId,
|
||||
path: this.data.top.linkPage
|
||||
});
|
||||
},
|
||||
goLink: function (e) {
|
||||
let index = e.currentTarget.id
|
||||
wx.vibrateShort()
|
||||
wx.openEmbeddedMiniProgram({
|
||||
appId: this.data.link[index].appId,
|
||||
path: this.data.link[index].linkPage
|
||||
});
|
||||
},
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"component": true
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<view class="jdwx-link-component">
|
||||
<view wx:if="{{top.appId}}" class="jdwx-applink-top" bindtap="gotopLink">
|
||||
<view><image src="{{top.appLogo}}" class="jdwx-applink-icon" /> </view>
|
||||
<view ><text class="jdwx-applink-top-linkname">{{top.linkName}}</text>
|
||||
<text class="jdwx-applink-top-text">{{top.appDsc}}</text> </view>
|
||||
</view>
|
||||
<view id="{{bindex}}" bindtap="goLink" wx:for="{{link}}" wx:for-index="bindex" wx:key="index" class="jdwx-applink-list">
|
||||
<image src="{{item.appLogo}}" class="jdwx-applink-icon" />
|
||||
<text class="jdwx-applink-text">{{item.linkName}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
/* 页面容器 */
|
||||
.jdwx-link-component {
|
||||
/* background-image: linear-gradient(to right, #4F9863, #4F9863); */
|
||||
background-attachment: fixed;
|
||||
background-size: cover; /* 确保背景图像覆盖整个元素 */
|
||||
/* height: 100vh; */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 20rpx;
|
||||
box-sizing: border-box;
|
||||
overflow: auto; /* 允许内容滚动 */
|
||||
}
|
||||
|
||||
/* 列表项样式 */
|
||||
.jdwx-applink-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 95%;
|
||||
/* 假设我们想要每个view的高度约为屏幕高度的1/8 */
|
||||
/* 使用小程序的wx.getSystemInfo API动态计算并设置这个值会更准确 */
|
||||
height: calc((100vh - 40rpx) / 10); /* 减去容器padding的影响 */
|
||||
padding: 20rpx;
|
||||
background-color: rgba(248, 250, 252, 1);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 8rpx;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
.jdwx-applink-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 95%;
|
||||
/* 假设我们想要每个view的高度约为屏幕高度的1/8 */
|
||||
/* 使用小程序的wx.getSystemInfo API动态计算并设置这个值会更准确 */
|
||||
height: calc((100vh - 40rpx) / 6); /* 减去容器padding的影响 */
|
||||
padding: 20rpx;
|
||||
border-radius: 8rpx;
|
||||
margin-bottom: 30rpx;
|
||||
background-color: rgba(248, 250, 252, 1);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.jdwx-applink-top-linkname{
|
||||
display: flex;
|
||||
font-size: 36rpx;
|
||||
color: rgb(39, 37, 37);
|
||||
padding-bottom: 10rpx;
|
||||
}
|
||||
/* 图标样式 */
|
||||
.jdwx-applink-icon {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
margin-right: 50rpx;
|
||||
margin-left: 30rpx;
|
||||
}
|
||||
|
||||
/* 文本样式 */
|
||||
.jdwx-applink-text {
|
||||
flex: 1;
|
||||
font-size: 32rpx;
|
||||
color: rgb(39, 37, 37);
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "template",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@jdmini/api": "^1.0.10",
|
||||
"@jdmini/components": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@jdmini/api": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmmirror.com/@jdmini/api/-/api-1.0.10.tgz",
|
||||
"integrity": "sha512-bVFU0awuY033mUT4QqArrYbrnPkBaBFKHoqCMHTVnRCk4b6gTs+cCGDH8uyf2t8ybCgWITKxaaH4Vjzyq8VF8g=="
|
||||
},
|
||||
"node_modules/@jdmini/components": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/@jdmini/components/-/components-1.0.6.tgz",
|
||||
"integrity": "sha512-ndva1nlZ1QJqDVgHfB0GPxMGmXsZ7SbWjUkRm/WoQIkow75fFbaQCW/xhtQQ+bPbJLjXmCg2p2356klsLLib8A==",
|
||||
"peerDependencies": {
|
||||
"@jdmini/api": ">=1.0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"@jdmini/api": "^1.0.10",
|
||||
"@jdmini/components": "^1.0.6"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { injectPage } from '@jdmini/api'
|
||||
const { CATEGORIES, ALL_COURSES } = require('../../utils/data.js')
|
||||
|
||||
Page(injectPage()({
|
||||
data: {
|
||||
categories: CATEGORIES,
|
||||
activeCategory: '',
|
||||
courseList: [],
|
||||
keyword: ''
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
const category = options.category ? decodeURIComponent(options.category) : CATEGORIES[0]
|
||||
const keyword = options.keyword ? decodeURIComponent(options.keyword) : ''
|
||||
this.setData({ activeCategory: category, keyword })
|
||||
this.filterCourses(category, keyword)
|
||||
},
|
||||
|
||||
filterCourses(category, keyword) {
|
||||
let list = ALL_COURSES
|
||||
if (keyword) {
|
||||
list = list.filter(c => c.title.includes(keyword) || c.desc.includes(keyword) || c.category.includes(keyword))
|
||||
} else {
|
||||
list = list.filter(c => c.category === category)
|
||||
}
|
||||
this.setData({ courseList: list })
|
||||
},
|
||||
|
||||
onCategorySwitch(e) {
|
||||
const cat = e.currentTarget.dataset.category
|
||||
this.setData({ activeCategory: cat, keyword: '' })
|
||||
this.filterCourses(cat, '')
|
||||
},
|
||||
|
||||
onCourseTap(e) {
|
||||
const { courseId } = e.currentTarget.dataset
|
||||
wx.navigateTo({ url: `/pages/course-detail/course-detail?courseId=${courseId}` })
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
return { title: '画画怎么画 — 课程分类', path: '/pages/home/home' }
|
||||
}
|
||||
}))
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"navigationBarTitleText": "课程分类",
|
||||
"backgroundColor": "#f7f8fc"
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
<view class="category-page">
|
||||
<!-- 分类切换 Tab -->
|
||||
<scroll-view class="tab-scroll" scroll-x scroll-with-animation>
|
||||
<view class="tab-list">
|
||||
<view
|
||||
class="tab-item {{activeCategory === item ? 'active' : ''}}"
|
||||
wx:for="{{categories}}"
|
||||
wx:key="*this"
|
||||
data-category="{{item}}"
|
||||
bindtap="onCategorySwitch"
|
||||
>
|
||||
<text>{{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 关键词提示 -->
|
||||
<view class="keyword-tip" wx:if="{{keyword}}">
|
||||
<text>搜索"{{keyword}}"的结果,共{{courseList.length}}个课程</text>
|
||||
</view>
|
||||
|
||||
<!-- 课程列表 -->
|
||||
<scroll-view class="course-scroll" scroll-y>
|
||||
<view class="course-grid" wx:if="{{courseList.length > 0}}">
|
||||
<view
|
||||
class="course-card"
|
||||
wx:for="{{courseList}}"
|
||||
wx:key="id"
|
||||
data-course-id="{{item.id}}"
|
||||
bindtap="onCourseTap"
|
||||
>
|
||||
<view class="course-cover" style="background:{{item.coverColor}}">
|
||||
<text class="course-cover-emoji">{{item.coverEmoji}}</text>
|
||||
</view>
|
||||
<view class="course-info">
|
||||
<text class="course-title">{{item.title}}</text>
|
||||
<view class="course-meta">
|
||||
<text class="badge badge-blue">{{item.difficulty}}</text>
|
||||
<text class="course-lessons">{{item.lessons}}节课</text>
|
||||
</view>
|
||||
<text class="course-duration">⏱ {{item.duration}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="empty-state" wx:else>
|
||||
<text class="empty-icon">🔍</text>
|
||||
<text class="empty-text">暂无该分类的课程</text>
|
||||
</view>
|
||||
<view style="height:40rpx"></view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
.category-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #f7f8fc;
|
||||
}
|
||||
|
||||
/* Tab 分类 */
|
||||
.tab-scroll {
|
||||
background: #ffffff;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1rpx solid #F0F0F0;
|
||||
}
|
||||
.tab-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 0 16rpx;
|
||||
}
|
||||
.tab-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 28rpx 24rpx;
|
||||
font-size: 28rpx;
|
||||
color: #666666;
|
||||
border-bottom: 4rpx solid transparent;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tab-item.active {
|
||||
color: #6C8CFF;
|
||||
font-weight: 600;
|
||||
border-bottom-color: #6C8CFF;
|
||||
}
|
||||
|
||||
/* 关键词提示 */
|
||||
.keyword-tip {
|
||||
padding: 16rpx 32rpx;
|
||||
font-size: 24rpx;
|
||||
color: #888888;
|
||||
background: #FFF8F0;
|
||||
border-bottom: 1rpx solid #FFE5B0;
|
||||
}
|
||||
|
||||
/* 课程列表 */
|
||||
.course-scroll {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.course-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20rpx;
|
||||
padding: 24rpx 24rpx 0;
|
||||
}
|
||||
.course-card {
|
||||
background: #ffffff;
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4rpx 16rpx rgba(108,140,255,0.08);
|
||||
}
|
||||
.course-cover {
|
||||
height: 200rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.course-cover-emoji {
|
||||
font-size: 72rpx;
|
||||
}
|
||||
.course-info {
|
||||
padding: 20rpx;
|
||||
}
|
||||
.course-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #333333;
|
||||
display: block;
|
||||
margin-bottom: 12rpx;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.course-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
.course-lessons {
|
||||
font-size: 22rpx;
|
||||
color: #AAAAAA;
|
||||
}
|
||||
.course-duration {
|
||||
font-size: 22rpx;
|
||||
color: #AAAAAA;
|
||||
display: block;
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import { injectPage } from '@jdmini/api'
|
||||
const { getCourseById } = require('../../utils/data.js')
|
||||
const storage = require('../../utils/storage.js')
|
||||
|
||||
Page(injectPage()({
|
||||
data: {
|
||||
course: null,
|
||||
progress: null,
|
||||
isFavorite: false,
|
||||
hasStarted: false
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
const courseId = options.courseId
|
||||
const course = getCourseById(courseId)
|
||||
if (!course) {
|
||||
wx.showToast({ title: '课程不存在', icon: 'none' })
|
||||
setTimeout(() => wx.navigateBack(), 1500)
|
||||
return
|
||||
}
|
||||
const progress = storage.getCourseProgress(courseId)
|
||||
const isFav = storage.isFavorite(courseId)
|
||||
this.setData({
|
||||
course,
|
||||
progress,
|
||||
isFavorite: isFav,
|
||||
hasStarted: progress.currentStep > 0 || progress.completed
|
||||
})
|
||||
},
|
||||
|
||||
onToggleFavorite() {
|
||||
const { course } = this.data
|
||||
const isFav = storage.toggleFavorite(course.id)
|
||||
this.setData({ isFavorite: isFav })
|
||||
wx.showToast({ title: isFav ? '已收藏' : '已取消收藏', icon: 'none' })
|
||||
},
|
||||
|
||||
onStartLearning() {
|
||||
const { course, progress } = this.data
|
||||
const stepIndex = progress.completed ? 0 : (progress.currentStep || 0)
|
||||
storage.addRecentHistory({
|
||||
courseId: course.id,
|
||||
courseTitle: course.title,
|
||||
coverEmoji: course.coverEmoji,
|
||||
coverColor: course.coverColor,
|
||||
stepIndex
|
||||
})
|
||||
wx.navigateTo({
|
||||
url: `/pages/study-step/study-step?courseId=${course.id}&stepIndex=${stepIndex}`
|
||||
})
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
const { course } = this.data
|
||||
return {
|
||||
title: `学画画:${course ? course.title : ''}`,
|
||||
path: `/pages/course-detail/course-detail?courseId=${course ? course.id : ''}`
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"navigationBarTitleText": "课程详情",
|
||||
"backgroundColor": "#f7f8fc"
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
<view class="detail-page" wx:if="{{course}}">
|
||||
<!-- 封面 -->
|
||||
<view class="detail-cover" style="background:{{course.coverColor}}">
|
||||
<text class="cover-emoji">{{course.coverEmoji}}</text>
|
||||
<view class="cover-overlay">
|
||||
<text class="cover-title">{{course.title}}</text>
|
||||
</view>
|
||||
<!-- 收藏按钮 -->
|
||||
<view class="fav-btn" bindtap="onToggleFavorite">
|
||||
<text>{{isFavorite ? '❤️' : '🤍'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view class="detail-body" scroll-y>
|
||||
<!-- 基本信息 -->
|
||||
<view class="info-section card">
|
||||
<view class="info-badges">
|
||||
<text class="badge badge-blue">{{course.difficulty}}</text>
|
||||
<text class="badge badge-orange">{{course.lessons}}节课</text>
|
||||
<text class="badge badge-green">⏱ {{course.duration}}</text>
|
||||
</view>
|
||||
<text class="info-desc">{{course.desc}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 课程详情 -->
|
||||
<view class="meta-section card">
|
||||
<view class="meta-item">
|
||||
<text class="meta-label">🎯 学习目标</text>
|
||||
<text class="meta-value">{{course.target}}</text>
|
||||
</view>
|
||||
<view class="divider"></view>
|
||||
<view class="meta-item">
|
||||
<text class="meta-label">👤 适合人群</text>
|
||||
<text class="meta-value">{{course.suitable}}</text>
|
||||
</view>
|
||||
<view class="divider"></view>
|
||||
<view class="meta-item">
|
||||
<text class="meta-label">🖌 所需工具</text>
|
||||
<text class="meta-value">{{course.tools}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 步骤列表 -->
|
||||
<view class="steps-section">
|
||||
<text class="steps-title">课程步骤({{course.steps.length}}步)</text>
|
||||
<view class="steps-list">
|
||||
<view class="step-item" wx:for="{{course.steps}}" wx:key="index">
|
||||
<view class="step-num {{progress.completed || progress.currentStep > index ? 'done' : progress.currentStep === index ? 'current' : ''}}">
|
||||
<text wx:if="{{progress.completed || progress.currentStep > index}}">✓</text>
|
||||
<text wx:else>{{index + 1}}</text>
|
||||
</view>
|
||||
<view class="step-content">
|
||||
<text class="step-name">{{item.title}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view style="height:200rpx"></view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<view class="detail-footer">
|
||||
<view class="progress-hint" wx:if="{{hasStarted && !progress.completed}}">
|
||||
<text>已学到第{{progress.currentStep + 1}}步,继续加油!</text>
|
||||
</view>
|
||||
<view class="progress-hint completed-hint" wx:if="{{progress.completed}}">
|
||||
<text>✅ 已完成本课,可重新学习</text>
|
||||
</view>
|
||||
<button class="btn-primary start-btn" bindtap="onStartLearning">
|
||||
{{progress.completed ? '再学一遍' : hasStarted ? '继续学习' : '开始学习'}}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
.detail-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* 封面 */
|
||||
.detail-cover {
|
||||
height: 380rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cover-emoji {
|
||||
font-size: 120rpx;
|
||||
}
|
||||
.cover-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(transparent, rgba(0,0,0,0.35));
|
||||
padding: 32rpx;
|
||||
}
|
||||
.cover-title {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
.fav-btn {
|
||||
position: absolute;
|
||||
top: 24rpx;
|
||||
right: 24rpx;
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
background: rgba(255,255,255,0.9);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 36rpx;
|
||||
}
|
||||
|
||||
/* 滚动内容 */
|
||||
.detail-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 24rpx 32rpx 0;
|
||||
}
|
||||
|
||||
/* 基本信息 */
|
||||
.info-section {
|
||||
padding: 28rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.info-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.info-desc {
|
||||
font-size: 28rpx;
|
||||
color: #555555;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* 元信息 */
|
||||
.meta-section {
|
||||
padding: 8rpx 28rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.meta-item {
|
||||
padding: 24rpx 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
}
|
||||
.meta-label {
|
||||
font-size: 26rpx;
|
||||
color: #888888;
|
||||
font-weight: 500;
|
||||
}
|
||||
.meta-value {
|
||||
font-size: 28rpx;
|
||||
color: #333333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 步骤列表 */
|
||||
.steps-section {
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.steps-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #333333;
|
||||
display: block;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.steps-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
background: #ffffff;
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
.step-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24rpx 28rpx;
|
||||
gap: 20rpx;
|
||||
border-bottom: 1rpx solid #F5F5F5;
|
||||
}
|
||||
.step-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.step-num {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 50%;
|
||||
background: #F0F0F0;
|
||||
color: #999999;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.step-num.done {
|
||||
background: #6CE5A0;
|
||||
color: #ffffff;
|
||||
}
|
||||
.step-num.current {
|
||||
background: #6C8CFF;
|
||||
color: #ffffff;
|
||||
}
|
||||
.step-name {
|
||||
font-size: 28rpx;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
/* 底部按钮 */
|
||||
.detail-footer {
|
||||
padding: 20rpx 32rpx;
|
||||
background: #ffffff;
|
||||
border-top: 1rpx solid #F0F0F0;
|
||||
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.progress-hint {
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: #888888;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
.completed-hint {
|
||||
color: #3CB371;
|
||||
}
|
||||
.start-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { injectPage } from '@jdmini/api'
|
||||
const { BEGINNER_PATH, CATEGORIES, DAILY_RECOMMEND, ALL_COURSES } = require('../../utils/data.js')
|
||||
const storage = require('../../utils/storage.js')
|
||||
|
||||
Page(injectPage()({
|
||||
data: {
|
||||
beginnerPath: BEGINNER_PATH,
|
||||
categories: CATEGORIES,
|
||||
dailyRecommend: DAILY_RECOMMEND,
|
||||
recentHistory: [],
|
||||
searchKeyword: ''
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.loadRecentHistory()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.loadRecentHistory()
|
||||
},
|
||||
|
||||
loadRecentHistory() {
|
||||
const history = storage.getRecentHistory()
|
||||
this.setData({ recentHistory: history.slice(0, 4) })
|
||||
},
|
||||
|
||||
onSearchInput(e) {
|
||||
this.setData({ searchKeyword: e.detail.value })
|
||||
},
|
||||
|
||||
onSearchConfirm() {
|
||||
const kw = this.data.searchKeyword.trim()
|
||||
if (!kw) return
|
||||
wx.navigateTo({
|
||||
url: `/pages/category/category?keyword=${encodeURIComponent(kw)}`
|
||||
})
|
||||
},
|
||||
|
||||
onPathCardTap(e) {
|
||||
const { courseId } = e.currentTarget.dataset
|
||||
wx.navigateTo({ url: `/pages/course-detail/course-detail?courseId=${courseId}` })
|
||||
},
|
||||
|
||||
onCategoryTap(e) {
|
||||
const { category } = e.currentTarget.dataset
|
||||
wx.navigateTo({ url: `/pages/category/category?category=${encodeURIComponent(category)}` })
|
||||
},
|
||||
|
||||
onRecommendTap(e) {
|
||||
const { courseId } = e.currentTarget.dataset
|
||||
wx.navigateTo({ url: `/pages/course-detail/course-detail?courseId=${courseId}` })
|
||||
},
|
||||
|
||||
onHistoryTap(e) {
|
||||
const { courseId } = e.currentTarget.dataset
|
||||
const progress = storage.getCourseProgress(courseId)
|
||||
wx.navigateTo({
|
||||
url: `/pages/study-step/study-step?courseId=${courseId}&stepIndex=${progress.currentStep}`
|
||||
})
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
return {
|
||||
title: '画画怎么画 — 零基础绘画学习',
|
||||
path: '/pages/home/home'
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"navigationBarTitleText": "画画怎么画",
|
||||
"navigationBarBackgroundColor": "#6C8CFF",
|
||||
"navigationBarTextStyle": "white",
|
||||
"backgroundColor": "#f7f8fc"
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
<scroll-view class="home-page" scroll-y>
|
||||
<!-- 顶部 header -->
|
||||
<view class="home-header">
|
||||
<view class="header-top">
|
||||
<view class="header-title-wrap">
|
||||
<text class="header-title">画画怎么画</text>
|
||||
<text class="header-slogan">从零开始,画出属于你的世界 🎨</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 搜索框 -->
|
||||
<view class="search-bar">
|
||||
<text class="search-icon">🔍</text>
|
||||
<input
|
||||
class="search-input"
|
||||
placeholder="搜索课程…"
|
||||
placeholder-style="color:#BBBBBB"
|
||||
value="{{searchKeyword}}"
|
||||
bindinput="onSearchInput"
|
||||
bindconfirm="onSearchConfirm"
|
||||
confirm-type="search"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="home-body">
|
||||
<!-- 零基础入门路径 -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">零基础入门路径</text>
|
||||
<text class="section-sub">按顺序学,稳稳起步</text>
|
||||
</view>
|
||||
<scroll-view class="path-scroll" scroll-x>
|
||||
<view class="path-list">
|
||||
<view
|
||||
class="path-card"
|
||||
wx:for="{{beginnerPath}}"
|
||||
wx:key="id"
|
||||
style="background:{{item.color}}"
|
||||
data-course-id="{{item.courseId}}"
|
||||
bindtap="onPathCardTap"
|
||||
>
|
||||
<text class="path-step">步骤 {{index + 1}}</text>
|
||||
<text class="path-icon">{{item.icon}}</text>
|
||||
<text class="path-title">{{item.title}}</text>
|
||||
<text class="path-desc">{{item.desc}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- 课程分类入口 -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">课程分类</text>
|
||||
</view>
|
||||
<view class="category-grid">
|
||||
<view
|
||||
class="category-item"
|
||||
wx:for="{{categories}}"
|
||||
wx:key="*this"
|
||||
data-category="{{item}}"
|
||||
bindtap="onCategoryTap"
|
||||
>
|
||||
<text class="category-emoji">{{index === 0 ? '✏️' : index === 1 ? '🧍' : index === 2 ? '🐾' : index === 3 ? '🌿' : index === 4 ? '🌄' : '📐'}}</text>
|
||||
<text class="category-name">{{item}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 今日推荐 -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">今日推荐</text>
|
||||
</view>
|
||||
<view class="recommend-list">
|
||||
<view
|
||||
class="recommend-card"
|
||||
wx:for="{{dailyRecommend}}"
|
||||
wx:key="id"
|
||||
data-course-id="{{item.id}}"
|
||||
bindtap="onRecommendTap"
|
||||
>
|
||||
<view class="recommend-cover" style="background:{{item.coverColor}}">
|
||||
<text class="recommend-emoji">{{item.coverEmoji}}</text>
|
||||
</view>
|
||||
<view class="recommend-info">
|
||||
<text class="recommend-title">{{item.title}}</text>
|
||||
<text class="recommend-desc">{{item.desc}}</text>
|
||||
<view class="recommend-meta">
|
||||
<text class="badge badge-blue">{{item.difficulty}}</text>
|
||||
<text class="recommend-lessons">{{item.lessons}}节课 · {{item.duration}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 最近学习记录 -->
|
||||
<view class="section" wx:if="{{recentHistory.length > 0}}">
|
||||
<view class="section-header">
|
||||
<text class="section-title">继续学习</text>
|
||||
</view>
|
||||
<view class="history-list">
|
||||
<view
|
||||
class="history-item"
|
||||
wx:for="{{recentHistory}}"
|
||||
wx:key="courseId"
|
||||
data-course-id="{{item.courseId}}"
|
||||
bindtap="onHistoryTap"
|
||||
>
|
||||
<view class="history-cover" style="background:{{item.coverColor}}">
|
||||
<text class="history-emoji">{{item.coverEmoji}}</text>
|
||||
</view>
|
||||
<view class="history-info">
|
||||
<text class="history-title">{{item.courseTitle}}</text>
|
||||
<text class="history-time">上次学习:第{{item.stepIndex + 1}}步</text>
|
||||
</view>
|
||||
<view class="history-arrow">›</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部占位 -->
|
||||
<view style="height:40rpx"></view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
.home-page {
|
||||
height: 100vh;
|
||||
background: #f7f8fc;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.home-header {
|
||||
background: linear-gradient(135deg, #6C8CFF 0%, #8BA4FF 100%);
|
||||
padding: 60rpx 32rpx 40rpx;
|
||||
}
|
||||
.header-top {
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
.header-title {
|
||||
display: block;
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
.header-slogan {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
color: rgba(255,255,255,0.85);
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
/* 搜索框 */
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
border-radius: 50rpx;
|
||||
padding: 18rpx 28rpx;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.search-icon {
|
||||
font-size: 30rpx;
|
||||
}
|
||||
.search-input {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: #333333;
|
||||
height: 40rpx;
|
||||
line-height: 40rpx;
|
||||
}
|
||||
|
||||
/* 主体 */
|
||||
.home-body {
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
|
||||
/* 通用section */
|
||||
.section {
|
||||
margin-top: 48rpx;
|
||||
}
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: #333333;
|
||||
}
|
||||
.section-sub {
|
||||
font-size: 24rpx;
|
||||
color: #AAAAAA;
|
||||
}
|
||||
|
||||
/* 入门路径 */
|
||||
.path-scroll {
|
||||
margin: 0 -32rpx;
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
.path-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 20rpx;
|
||||
padding-right: 32rpx;
|
||||
width: max-content;
|
||||
}
|
||||
.path-card {
|
||||
width: 240rpx;
|
||||
border-radius: 24rpx;
|
||||
padding: 28rpx 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.path-step {
|
||||
font-size: 22rpx;
|
||||
color: rgba(255,255,255,0.75);
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
.path-icon {
|
||||
font-size: 56rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
.path-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
.path-desc {
|
||||
font-size: 22rpx;
|
||||
color: rgba(255,255,255,0.85);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 分类网格 */
|
||||
.category-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20rpx;
|
||||
}
|
||||
.category-item {
|
||||
background: #ffffff;
|
||||
border-radius: 20rpx;
|
||||
padding: 28rpx 16rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
box-shadow: 0 4rpx 16rpx rgba(108,140,255,0.08);
|
||||
}
|
||||
.category-emoji {
|
||||
font-size: 48rpx;
|
||||
}
|
||||
.category-name {
|
||||
font-size: 26rpx;
|
||||
color: #333333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 今日推荐 */
|
||||
.recommend-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.recommend-card {
|
||||
background: #ffffff;
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4rpx 16rpx rgba(108,140,255,0.08);
|
||||
}
|
||||
.recommend-cover {
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.recommend-emoji {
|
||||
font-size: 64rpx;
|
||||
}
|
||||
.recommend-info {
|
||||
flex: 1;
|
||||
padding: 24rpx 24rpx 24rpx 20rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.recommend-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #333333;
|
||||
}
|
||||
.recommend-desc {
|
||||
font-size: 24rpx;
|
||||
color: #888888;
|
||||
line-height: 1.5;
|
||||
margin: 8rpx 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.recommend-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.recommend-lessons {
|
||||
font-size: 22rpx;
|
||||
color: #AAAAAA;
|
||||
}
|
||||
|
||||
/* 最近学习 */
|
||||
.history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.history-item {
|
||||
background: #ffffff;
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx;
|
||||
gap: 20rpx;
|
||||
box-shadow: 0 4rpx 16rpx rgba(108,140,255,0.06);
|
||||
}
|
||||
.history-cover {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.history-emoji {
|
||||
font-size: 36rpx;
|
||||
}
|
||||
.history-info {
|
||||
flex: 1;
|
||||
}
|
||||
.history-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #333333;
|
||||
display: block;
|
||||
}
|
||||
.history-time {
|
||||
font-size: 24rpx;
|
||||
color: #AAAAAA;
|
||||
margin-top: 6rpx;
|
||||
display: block;
|
||||
}
|
||||
.history-arrow {
|
||||
font-size: 36rpx;
|
||||
color: #CCCCCC;
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import { injectPage } from '@jdmini/api'
|
||||
const { PRACTICE_TASKS } = require('../../utils/data.js')
|
||||
const storage = require('../../utils/storage.js')
|
||||
|
||||
Page(injectPage()({
|
||||
data: {
|
||||
allTasks: PRACTICE_TASKS,
|
||||
filteredTasks: PRACTICE_TASKS,
|
||||
activeDifficulty: '全部',
|
||||
difficulties: ['全部', '入门', '初级'],
|
||||
worksRecords: [],
|
||||
completedCount: 0
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.loadData()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.loadData()
|
||||
},
|
||||
|
||||
loadData() {
|
||||
const records = storage.getWorksRecords()
|
||||
const completedIds = records.map(r => r.courseId)
|
||||
const tasks = this.data.allTasks.map(t => ({
|
||||
...t,
|
||||
done: completedIds.includes(t.courseId)
|
||||
}))
|
||||
this.setData({
|
||||
allTasks: tasks,
|
||||
filteredTasks: this.filterByDifficulty(tasks, this.data.activeDifficulty),
|
||||
completedCount: completedIds.length
|
||||
})
|
||||
},
|
||||
|
||||
filterByDifficulty(tasks, diff) {
|
||||
if (diff === '全部') return tasks
|
||||
return tasks.filter(t => t.difficulty === diff)
|
||||
},
|
||||
|
||||
onDifficultySwitch(e) {
|
||||
const diff = e.currentTarget.dataset.diff
|
||||
this.setData({
|
||||
activeDifficulty: diff,
|
||||
filteredTasks: this.filterByDifficulty(this.data.allTasks, diff)
|
||||
})
|
||||
},
|
||||
|
||||
onTaskTap(e) {
|
||||
const { courseId, stepIndex } = e.currentTarget.dataset
|
||||
wx.navigateTo({
|
||||
url: `/pages/study-step/study-step?courseId=${courseId}&stepIndex=${stepIndex}`
|
||||
})
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
return { title: '画画怎么画 — 每日练习', path: '/pages/practice/practice' }
|
||||
}
|
||||
}))
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"navigationBarTitleText": "每日练习",
|
||||
"backgroundColor": "#f7f8fc"
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
<view class="practice-page">
|
||||
<!-- 顶部Banner -->
|
||||
<view class="practice-banner">
|
||||
<view class="banner-left">
|
||||
<text class="banner-title">今日练习</text>
|
||||
<text class="banner-sub">每天练一点,进步看得见</text>
|
||||
</view>
|
||||
<view class="banner-stats">
|
||||
<text class="stats-num">{{completedCount}}</text>
|
||||
<text class="stats-label">已完成</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 难度筛选 -->
|
||||
<view class="filter-bar">
|
||||
<view
|
||||
class="filter-item {{activeDifficulty === item ? 'active' : ''}}"
|
||||
wx:for="{{difficulties}}"
|
||||
wx:key="*this"
|
||||
data-diff="{{item}}"
|
||||
bindtap="onDifficultySwitch"
|
||||
>{{item}}</view>
|
||||
</view>
|
||||
|
||||
<!-- 练习任务列表 -->
|
||||
<scroll-view class="task-scroll" scroll-y>
|
||||
<view class="task-list" wx:if="{{filteredTasks.length > 0}}">
|
||||
<view
|
||||
class="task-card {{item.done ? 'done' : ''}}"
|
||||
wx:for="{{filteredTasks}}"
|
||||
wx:key="id"
|
||||
data-course-id="{{item.courseId}}"
|
||||
data-step-index="{{item.stepIndex}}"
|
||||
bindtap="onTaskTap"
|
||||
>
|
||||
<view class="task-icon-wrap" style="background:{{item.iconColor}}">
|
||||
<text class="task-icon">{{item.icon}}</text>
|
||||
</view>
|
||||
<view class="task-info">
|
||||
<view class="task-title-row">
|
||||
<text class="task-title">{{item.title}}</text>
|
||||
<view class="task-done-badge" wx:if="{{item.done}}">
|
||||
<text>✓ 已完成</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="task-type-badge">{{item.type}}</text>
|
||||
<text class="task-desc">{{item.desc}}</text>
|
||||
<view class="task-meta">
|
||||
<text class="badge badge-{{item.difficulty === '入门' ? 'blue' : 'orange'}}">{{item.difficulty}}</text>
|
||||
<text class="task-duration">⏱ {{item.duration}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="task-arrow">›</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="empty-state" wx:else>
|
||||
<text class="empty-icon">📝</text>
|
||||
<text class="empty-text">没有该难度的练习</text>
|
||||
</view>
|
||||
<view style="height:40rpx"></view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
.practice-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #f7f8fc;
|
||||
}
|
||||
|
||||
/* Banner */
|
||||
.practice-banner {
|
||||
background: linear-gradient(135deg, #FFB84D 0%, #FF9A1F 100%);
|
||||
padding: 40rpx 32rpx;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.banner-title {
|
||||
display: block;
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
.banner-sub {
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
color: rgba(255,255,255,0.85);
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
.banner-stats {
|
||||
text-align: center;
|
||||
background: rgba(255,255,255,0.2);
|
||||
border-radius: 20rpx;
|
||||
padding: 16rpx 32rpx;
|
||||
}
|
||||
.stats-num {
|
||||
display: block;
|
||||
font-size: 56rpx;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
line-height: 1;
|
||||
}
|
||||
.stats-label {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
color: rgba(255,255,255,0.85);
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
/* 筛选栏 */
|
||||
.filter-bar {
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 0 24rpx;
|
||||
border-bottom: 1rpx solid #F0F0F0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.filter-item {
|
||||
padding: 24rpx 20rpx;
|
||||
font-size: 28rpx;
|
||||
color: #666666;
|
||||
border-bottom: 4rpx solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.filter-item.active {
|
||||
color: #FFB84D;
|
||||
font-weight: 600;
|
||||
border-bottom-color: #FFB84D;
|
||||
}
|
||||
|
||||
/* 任务列表 */
|
||||
.task-scroll {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
padding: 24rpx 32rpx 0;
|
||||
}
|
||||
.task-card {
|
||||
background: #ffffff;
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24rpx;
|
||||
gap: 20rpx;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.06);
|
||||
}
|
||||
.task-card.done {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.task-icon-wrap {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.task-icon {
|
||||
font-size: 44rpx;
|
||||
}
|
||||
.task-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
.task-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.task-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #333333;
|
||||
}
|
||||
.task-done-badge {
|
||||
background: #E8FAF0;
|
||||
color: #3CB371;
|
||||
font-size: 20rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
.task-type-badge {
|
||||
font-size: 22rpx;
|
||||
color: #AAAAAA;
|
||||
}
|
||||
.task-desc {
|
||||
font-size: 24rpx;
|
||||
color: #888888;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.task-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
.task-duration {
|
||||
font-size: 22rpx;
|
||||
color: #AAAAAA;
|
||||
}
|
||||
.task-arrow {
|
||||
font-size: 36rpx;
|
||||
color: #CCCCCC;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import { injectPage } from '@jdmini/api'
|
||||
const { getCourseById, ALL_COURSES } = require('../../utils/data.js')
|
||||
const storage = require('../../utils/storage.js')
|
||||
|
||||
Page(injectPage()({
|
||||
data: {
|
||||
settings: null,
|
||||
completedCount: 0,
|
||||
practiceCount: 0,
|
||||
favoriteCourses: [],
|
||||
recentWorks: [],
|
||||
showReminderModal: false
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.loadData()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.loadData()
|
||||
},
|
||||
|
||||
loadData() {
|
||||
const settings = storage.initSettings()
|
||||
const completedCount = storage.getCompletedCount()
|
||||
const practiceCount = storage.getCompletedPracticeCount()
|
||||
const favoriteIds = storage.getFavorites()
|
||||
const favoriteCourses = favoriteIds
|
||||
.map(id => getCourseById(id))
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
const recentWorks = storage.getWorksRecords().slice(0, 6)
|
||||
|
||||
this.setData({
|
||||
settings,
|
||||
completedCount,
|
||||
practiceCount,
|
||||
favoriteCourses,
|
||||
recentWorks
|
||||
})
|
||||
},
|
||||
|
||||
onToggleReminder() {
|
||||
const { settings } = this.data
|
||||
const newVal = !settings.reminderEnabled
|
||||
const updated = storage.saveSettings({ reminderEnabled: newVal })
|
||||
this.setData({ settings: updated })
|
||||
wx.showToast({ title: newVal ? '学习提醒已开启' : '学习提醒已关闭', icon: 'none' })
|
||||
},
|
||||
|
||||
onFavCourseTap(e) {
|
||||
const { courseId } = e.currentTarget.dataset
|
||||
wx.navigateTo({ url: `/pages/course-detail/course-detail?courseId=${courseId}` })
|
||||
},
|
||||
|
||||
onWorkTap(e) {
|
||||
const { imagePath } = e.currentTarget.dataset
|
||||
wx.previewImage({ urls: [imagePath], current: imagePath })
|
||||
},
|
||||
|
||||
onAllCategoryTap() {
|
||||
wx.navigateTo({ url: '/pages/category/category' })
|
||||
},
|
||||
|
||||
onHelpTap() {
|
||||
wx.showModal({
|
||||
title: '帮助说明',
|
||||
content: '零基础也能学会画画!从「零基础入门路径」开始,跟着步骤一步步完成,每天坚持练习,你会进步很快的 🎨',
|
||||
showCancel: false,
|
||||
confirmText: '知道了'
|
||||
})
|
||||
},
|
||||
|
||||
onPrivacyTap() {
|
||||
wx.showModal({
|
||||
title: '隐私说明',
|
||||
content: '本小程序所有学习数据(进度、收藏、作品)均保存在你的手机本地,不会上传到服务器,请放心使用。',
|
||||
showCancel: false,
|
||||
confirmText: '好的'
|
||||
})
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
return { title: '画画怎么画 — 零基础绘画学习', path: '/pages/home/home' }
|
||||
}
|
||||
}))
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"navigationBarTitleText": "我的",
|
||||
"backgroundColor": "#f7f8fc"
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
<scroll-view class="profile-page" scroll-y>
|
||||
<!-- 顶部用户信息 -->
|
||||
<view class="profile-header">
|
||||
<view class="avatar-wrap">
|
||||
<text class="avatar-emoji">🎨</text>
|
||||
</view>
|
||||
<view class="user-info">
|
||||
<text class="user-name">{{settings ? settings.nickname : '学画的你'}}</text>
|
||||
<view class="user-days">
|
||||
<text class="days-num">{{settings ? settings.joinDays : 1}}</text>
|
||||
<text class="days-label">天</text>
|
||||
<text class="days-text">坚持学习</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 数据统计 -->
|
||||
<view class="stats-section card">
|
||||
<view class="stats-item">
|
||||
<text class="stats-value">{{completedCount}}</text>
|
||||
<text class="stats-key">已学课程</text>
|
||||
</view>
|
||||
<view class="stats-divider"></view>
|
||||
<view class="stats-item">
|
||||
<text class="stats-value">{{practiceCount}}</text>
|
||||
<text class="stats-key">完成练习</text>
|
||||
</view>
|
||||
<view class="stats-divider"></view>
|
||||
<view class="stats-item">
|
||||
<text class="stats-value">{{favoriteCourses.length}}</text>
|
||||
<text class="stats-key">收藏课程</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 收藏课程 -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">收藏课程</text>
|
||||
</view>
|
||||
<view class="fav-list" wx:if="{{favoriteCourses.length > 0}}">
|
||||
<view
|
||||
class="fav-card"
|
||||
wx:for="{{favoriteCourses}}"
|
||||
wx:key="id"
|
||||
data-course-id="{{item.id}}"
|
||||
bindtap="onFavCourseTap"
|
||||
>
|
||||
<view class="fav-cover" style="background:{{item.coverColor}}">
|
||||
<text class="fav-emoji">{{item.coverEmoji}}</text>
|
||||
</view>
|
||||
<text class="fav-title">{{item.title}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="empty-tip" wx:else>
|
||||
<text>暂无收藏,去课程详情页收藏你喜欢的课程吧</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 最近作品 -->
|
||||
<view class="section" wx:if="{{recentWorks.length > 0}}">
|
||||
<view class="section-header">
|
||||
<text class="section-title">最近作品</text>
|
||||
</view>
|
||||
<scroll-view class="works-scroll" scroll-x>
|
||||
<view class="works-list">
|
||||
<view
|
||||
class="work-thumb"
|
||||
wx:for="{{recentWorks}}"
|
||||
wx:key="id"
|
||||
data-image-path="{{item.imagePath}}"
|
||||
bindtap="onWorkTap"
|
||||
>
|
||||
<image class="work-thumb-img" src="{{item.imagePath}}" mode="aspectFill" />
|
||||
<text class="work-thumb-title">{{item.courseTitle}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- 设置入口 -->
|
||||
<view class="settings-section">
|
||||
<view class="settings-item" bindtap="onToggleReminder">
|
||||
<view class="settings-left">
|
||||
<text class="settings-icon">🔔</text>
|
||||
<text class="settings-label">学习提醒</text>
|
||||
</view>
|
||||
<view class="settings-right">
|
||||
<text class="settings-value">{{settings && settings.reminderEnabled ? '已开启' : '已关闭'}}</text>
|
||||
<text class="settings-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="divider"></view>
|
||||
<view class="settings-item" bindtap="onHelpTap">
|
||||
<view class="settings-left">
|
||||
<text class="settings-icon">❓</text>
|
||||
<text class="settings-label">帮助说明</text>
|
||||
</view>
|
||||
<text class="settings-arrow">›</text>
|
||||
</view>
|
||||
<view class="divider"></view>
|
||||
<view class="settings-item" bindtap="onPrivacyTap">
|
||||
<view class="settings-left">
|
||||
<text class="settings-icon">🔒</text>
|
||||
<text class="settings-label">隐私说明</text>
|
||||
</view>
|
||||
<text class="settings-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view style="height:40rpx"></view>
|
||||
</scroll-view>
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
.profile-page {
|
||||
height: 100vh;
|
||||
background: #f7f8fc;
|
||||
}
|
||||
|
||||
/* 顶部用户信息 */
|
||||
.profile-header {
|
||||
background: linear-gradient(135deg, #6C8CFF 0%, #8BA4FF 100%);
|
||||
padding: 48rpx 32rpx 40rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28rpx;
|
||||
}
|
||||
.avatar-wrap {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.25);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.avatar-emoji {
|
||||
font-size: 60rpx;
|
||||
}
|
||||
.user-info {
|
||||
flex: 1;
|
||||
}
|
||||
.user-name {
|
||||
display: block;
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
.user-days {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4rpx;
|
||||
}
|
||||
.days-num {
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
color: #FFB84D;
|
||||
line-height: 1;
|
||||
}
|
||||
.days-label {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255,255,255,0.85);
|
||||
}
|
||||
.days-text {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255,255,255,0.75);
|
||||
margin-left: 6rpx;
|
||||
}
|
||||
|
||||
/* 数据统计 */
|
||||
.stats-section {
|
||||
margin: 24rpx 32rpx 0;
|
||||
padding: 32rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.stats-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
.stats-value {
|
||||
font-size: 52rpx;
|
||||
font-weight: 700;
|
||||
color: #6C8CFF;
|
||||
line-height: 1;
|
||||
}
|
||||
.stats-key {
|
||||
font-size: 22rpx;
|
||||
color: #888888;
|
||||
}
|
||||
.stats-divider {
|
||||
width: 1rpx;
|
||||
height: 60rpx;
|
||||
background: #F0F0F0;
|
||||
}
|
||||
|
||||
/* 通用section */
|
||||
.section {
|
||||
margin: 32rpx 32rpx 0;
|
||||
}
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
/* 收藏课程 */
|
||||
.fav-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16rpx;
|
||||
}
|
||||
.fav-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
}
|
||||
.fav-cover {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.fav-emoji {
|
||||
font-size: 44rpx;
|
||||
}
|
||||
.fav-title {
|
||||
font-size: 20rpx;
|
||||
color: #555555;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.empty-tip {
|
||||
font-size: 24rpx;
|
||||
color: #BBBBBB;
|
||||
text-align: center;
|
||||
padding: 32rpx 0;
|
||||
}
|
||||
|
||||
/* 最近作品 */
|
||||
.works-scroll {
|
||||
margin: 0 -32rpx;
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
.works-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 16rpx;
|
||||
padding-right: 32rpx;
|
||||
width: max-content;
|
||||
}
|
||||
.work-thumb {
|
||||
width: 180rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.work-thumb-img {
|
||||
width: 180rpx;
|
||||
height: 180rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #EEEEEE;
|
||||
}
|
||||
.work-thumb-title {
|
||||
display: block;
|
||||
font-size: 20rpx;
|
||||
color: #888888;
|
||||
text-align: center;
|
||||
margin-top: 8rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 设置列表 */
|
||||
.settings-section {
|
||||
margin: 32rpx 32rpx 0;
|
||||
background: #ffffff;
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
.settings-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 28rpx 28rpx;
|
||||
}
|
||||
.settings-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.settings-icon {
|
||||
font-size: 36rpx;
|
||||
}
|
||||
.settings-label {
|
||||
font-size: 28rpx;
|
||||
color: #333333;
|
||||
}
|
||||
.settings-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
.settings-value {
|
||||
font-size: 26rpx;
|
||||
color: #AAAAAA;
|
||||
}
|
||||
.settings-arrow {
|
||||
font-size: 36rpx;
|
||||
color: #CCCCCC;
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import { injectPage } from '@jdmini/api'
|
||||
const { getCourseById } = require('../../utils/data.js')
|
||||
const storage = require('../../utils/storage.js')
|
||||
|
||||
Page(injectPage()({
|
||||
data: {
|
||||
course: null,
|
||||
currentStepIndex: 0,
|
||||
totalSteps: 0,
|
||||
currentStep: null,
|
||||
isLastStep: false,
|
||||
progressPercent: 0
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
const courseId = options.courseId
|
||||
const stepIndex = parseInt(options.stepIndex) || 0
|
||||
const course = getCourseById(courseId)
|
||||
if (!course) {
|
||||
wx.showToast({ title: '课程不存在', icon: 'none' })
|
||||
setTimeout(() => wx.navigateBack(), 1500)
|
||||
return
|
||||
}
|
||||
wx.setNavigationBarTitle({ title: course.title })
|
||||
this.setCourse(course, stepIndex)
|
||||
},
|
||||
|
||||
setCourse(course, stepIndex) {
|
||||
const total = course.steps.length
|
||||
const safeIndex = Math.max(0, Math.min(stepIndex, total - 1))
|
||||
const percent = Math.round((safeIndex / total) * 100)
|
||||
this.setData({
|
||||
course,
|
||||
totalSteps: total,
|
||||
currentStepIndex: safeIndex,
|
||||
currentStep: course.steps[safeIndex],
|
||||
isLastStep: safeIndex === total - 1,
|
||||
progressPercent: percent
|
||||
})
|
||||
// 保存进度
|
||||
storage.saveProgress(course.id, safeIndex, false)
|
||||
// 更新最近记录
|
||||
storage.addRecentHistory({
|
||||
courseId: course.id,
|
||||
courseTitle: course.title,
|
||||
coverEmoji: course.coverEmoji,
|
||||
coverColor: course.coverColor,
|
||||
stepIndex: safeIndex
|
||||
})
|
||||
},
|
||||
|
||||
onPrevStep() {
|
||||
const { currentStepIndex, course } = this.data
|
||||
if (currentStepIndex <= 0) return
|
||||
this.setCourse(course, currentStepIndex - 1)
|
||||
},
|
||||
|
||||
onNextStep() {
|
||||
const { currentStepIndex, course, isLastStep } = this.data
|
||||
if (isLastStep) {
|
||||
this.onFinishCourse()
|
||||
return
|
||||
}
|
||||
this.setCourse(course, currentStepIndex + 1)
|
||||
},
|
||||
|
||||
onFinishCourse() {
|
||||
const { course } = this.data
|
||||
// 标记完成
|
||||
storage.saveProgress(course.id, course.steps.length - 1, true)
|
||||
wx.navigateTo({
|
||||
url: `/pages/work-submit/work-submit?courseId=${course.id}`
|
||||
})
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
const { course } = this.data
|
||||
return {
|
||||
title: `我在学:${course ? course.title : ''}`,
|
||||
path: `/pages/course-detail/course-detail?courseId=${course ? course.id : ''}`
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"navigationBarTitleText": "学习",
|
||||
"backgroundColor": "#f7f8fc"
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
<view class="step-page" wx:if="{{course}}">
|
||||
<!-- 顶部进度 -->
|
||||
<view class="step-header">
|
||||
<view class="step-progress-bar">
|
||||
<view class="step-progress-fill" style="width:{{progressPercent}}%"></view>
|
||||
</view>
|
||||
<view class="step-counter">
|
||||
<text class="step-current">第 {{currentStepIndex + 1}} 步</text>
|
||||
<text class="step-total">共 {{totalSteps}} 步</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 主内容滚动区 -->
|
||||
<scroll-view class="step-body" scroll-y>
|
||||
<!-- 步骤标题 -->
|
||||
<view class="step-title-wrap">
|
||||
<view class="step-badge">步骤 {{currentStepIndex + 1}}</view>
|
||||
<text class="step-name">{{currentStep.title}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 示意图区域 -->
|
||||
<view class="step-image-wrap">
|
||||
<view class="step-image-placeholder" style="background:{{course.coverColor}}">
|
||||
<text class="step-image-emoji">{{currentStep.imageEmoji}}</text>
|
||||
<text class="step-image-hint">示意图</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 讲解文字 -->
|
||||
<view class="step-desc-wrap card">
|
||||
<text class="step-desc-title">📖 步骤说明</text>
|
||||
<text class="step-desc">{{currentStep.desc}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 关键提示 -->
|
||||
<view class="step-tip-wrap">
|
||||
<text class="step-tip">{{currentStep.tip}}</text>
|
||||
</view>
|
||||
|
||||
<view style="height:200rpx"></view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部操作按钮 -->
|
||||
<view class="step-footer">
|
||||
<button
|
||||
class="step-btn btn-prev {{currentStepIndex === 0 ? 'disabled' : ''}}"
|
||||
bindtap="onPrevStep"
|
||||
disabled="{{currentStepIndex === 0}}"
|
||||
>上一步</button>
|
||||
<button
|
||||
class="step-btn btn-next"
|
||||
bindtap="onNextStep"
|
||||
>{{isLastStep ? '完成本课 🎉' : '下一步 →'}}</button>
|
||||
</view>
|
||||
</view>
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
.step-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #f7f8fc;
|
||||
}
|
||||
|
||||
/* 顶部进度 */
|
||||
.step-header {
|
||||
background: #ffffff;
|
||||
padding: 20rpx 32rpx 16rpx;
|
||||
border-bottom: 1rpx solid #F0F0F0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.step-progress-bar {
|
||||
height: 8rpx;
|
||||
background: #EEEEEE;
|
||||
border-radius: 4rpx;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
.step-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #6C8CFF, #FFB84D);
|
||||
border-radius: 4rpx;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.step-counter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.step-current {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #6C8CFF;
|
||||
}
|
||||
.step-total {
|
||||
font-size: 24rpx;
|
||||
color: #AAAAAA;
|
||||
}
|
||||
|
||||
/* 主内容 */
|
||||
.step-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.step-title-wrap {
|
||||
padding: 32rpx 32rpx 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.step-badge {
|
||||
background: #EEF1FF;
|
||||
color: #6C8CFF;
|
||||
font-size: 22rpx;
|
||||
padding: 6rpx 18rpx;
|
||||
border-radius: 20rpx;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.step-name {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
/* 示意图 */
|
||||
.step-image-wrap {
|
||||
padding: 24rpx 32rpx;
|
||||
}
|
||||
.step-image-placeholder {
|
||||
border-radius: 24rpx;
|
||||
height: 380rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.step-image-emoji {
|
||||
font-size: 100rpx;
|
||||
}
|
||||
.step-image-hint {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255,255,255,0.7);
|
||||
}
|
||||
|
||||
/* 讲解文字 */
|
||||
.step-desc-wrap {
|
||||
margin: 0 32rpx 20rpx;
|
||||
padding: 28rpx;
|
||||
}
|
||||
.step-desc-title {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: #888888;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
.step-desc {
|
||||
font-size: 30rpx;
|
||||
color: #333333;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
/* 关键提示 */
|
||||
.step-tip-wrap {
|
||||
margin: 0 32rpx 20rpx;
|
||||
background: #FFF9E6;
|
||||
border-left: 6rpx solid #FFB84D;
|
||||
border-radius: 0 16rpx 16rpx 0;
|
||||
padding: 20rpx 24rpx;
|
||||
}
|
||||
.step-tip {
|
||||
font-size: 26rpx;
|
||||
color: #B8860B;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 底部按钮 */
|
||||
.step-footer {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
padding: 20rpx 32rpx;
|
||||
background: #ffffff;
|
||||
border-top: 1rpx solid #F0F0F0;
|
||||
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.step-btn {
|
||||
flex: 1;
|
||||
border-radius: 50rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 500;
|
||||
padding: 24rpx 0;
|
||||
text-align: center;
|
||||
}
|
||||
.step-btn::after { border: none; }
|
||||
.btn-prev {
|
||||
background: #F0F0F0;
|
||||
color: #666666;
|
||||
}
|
||||
.btn-prev.disabled {
|
||||
background: #F5F5F5;
|
||||
color: #CCCCCC;
|
||||
}
|
||||
.btn-next {
|
||||
flex: 2;
|
||||
background: #6C8CFF;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { injectPage } from '@jdmini/api'
|
||||
const { getCourseById } = require('../../utils/data.js')
|
||||
const storage = require('../../utils/storage.js')
|
||||
|
||||
Page(injectPage()({
|
||||
data: {
|
||||
course: null,
|
||||
myWorkPath: '',
|
||||
viewMode: 'example', // 'example' | 'mywork' | 'compare'
|
||||
saved: false
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
const courseId = options.courseId
|
||||
const course = getCourseById(courseId)
|
||||
this.setData({ course: course || null })
|
||||
},
|
||||
|
||||
onSwitchView(e) {
|
||||
const { mode } = e.currentTarget.dataset
|
||||
this.setData({ viewMode: mode })
|
||||
},
|
||||
|
||||
onChooseWork() {
|
||||
wx.chooseMedia({
|
||||
count: 1,
|
||||
mediaType: ['image'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: (res) => {
|
||||
const filePath = res.tempFiles[0].tempFilePath
|
||||
this.setData({ myWorkPath: filePath, viewMode: 'compare', saved: false })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
onSaveRecord() {
|
||||
const { course, myWorkPath } = this.data
|
||||
if (!myWorkPath) {
|
||||
wx.showToast({ title: '请先上传你的作品', icon: 'none' })
|
||||
return
|
||||
}
|
||||
storage.addWorkRecord({
|
||||
courseId: course ? course.id : '',
|
||||
courseTitle: course ? course.title : '',
|
||||
imagePath: myWorkPath
|
||||
})
|
||||
this.setData({ saved: true })
|
||||
wx.showToast({ title: '保存成功!', icon: 'success' })
|
||||
},
|
||||
|
||||
onContinueLearning() {
|
||||
wx.navigateBack({ delta: 2 })
|
||||
},
|
||||
|
||||
onBackToHome() {
|
||||
wx.switchTab({ url: '/pages/home/home' })
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
return {
|
||||
title: '我完成了一节绘画课!',
|
||||
path: '/pages/home/home'
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"navigationBarTitleText": "作品记录",
|
||||
"backgroundColor": "#f7f8fc"
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
<view class="submit-page">
|
||||
<!-- 顶部完成横幅 -->
|
||||
<view class="complete-banner">
|
||||
<text class="complete-emoji">🎉</text>
|
||||
<text class="complete-title">完成本课!</text>
|
||||
<text class="complete-sub">{{course ? course.title : ''}} · 学完啦</text>
|
||||
</view>
|
||||
|
||||
<!-- 视图切换 -->
|
||||
<view class="view-switch">
|
||||
<view
|
||||
class="switch-item {{viewMode === 'example' ? 'active' : ''}}"
|
||||
data-mode="example"
|
||||
bindtap="onSwitchView"
|
||||
>示例作品</view>
|
||||
<view
|
||||
class="switch-item {{viewMode === 'mywork' ? 'active' : ''}}"
|
||||
data-mode="mywork"
|
||||
bindtap="onSwitchView"
|
||||
>我的作品</view>
|
||||
<view
|
||||
class="switch-item {{viewMode === 'compare' ? 'active' : ''}}"
|
||||
data-mode="compare"
|
||||
bindtap="onSwitchView"
|
||||
>对比查看</view>
|
||||
</view>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<scroll-view class="submit-body" scroll-y>
|
||||
<!-- 示例图 -->
|
||||
<view class="work-display" wx:if="{{viewMode === 'example'}}">
|
||||
<view class="work-frame example-frame" style="background:{{course ? course.coverColor : '#f0f0f0'}}">
|
||||
<text class="work-emoji">{{course ? course.coverEmoji : '🎨'}}</text>
|
||||
<text class="work-label">范例参考图</text>
|
||||
</view>
|
||||
<text class="work-hint">这是本课的参考范例,上传你的作品后可以对比查看</text>
|
||||
</view>
|
||||
|
||||
<!-- 我的作品 -->
|
||||
<view class="work-display" wx:if="{{viewMode === 'mywork'}}">
|
||||
<view class="work-frame my-frame" bindtap="onChooseWork" wx:if="{{!myWorkPath}}">
|
||||
<text class="upload-icon">📷</text>
|
||||
<text class="upload-text">点击上传我的作品</text>
|
||||
<text class="upload-hint">支持相册选择或拍照</text>
|
||||
</view>
|
||||
<view class="work-frame" wx:else bindtap="onChooseWork">
|
||||
<image class="work-image" src="{{myWorkPath}}" mode="aspectFit" />
|
||||
<text class="reupload-hint">点击重新上传</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 对比查看 -->
|
||||
<view class="compare-display" wx:if="{{viewMode === 'compare'}}">
|
||||
<view class="compare-row">
|
||||
<view class="compare-item">
|
||||
<text class="compare-label">范例</text>
|
||||
<view class="compare-frame example-frame" style="background:{{course ? course.coverColor : '#f0f0f0'}}">
|
||||
<text class="compare-emoji">{{course ? course.coverEmoji : '🎨'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="compare-divider">VS</view>
|
||||
<view class="compare-item">
|
||||
<text class="compare-label">我的作品</text>
|
||||
<view class="compare-frame my-frame" wx:if="{{!myWorkPath}}" bindtap="onChooseWork">
|
||||
<text class="upload-icon-sm">📷</text>
|
||||
<text class="upload-text-sm">点击上传</text>
|
||||
</view>
|
||||
<view class="compare-frame" wx:else bindtap="onChooseWork">
|
||||
<image class="compare-image" src="{{myWorkPath}}" mode="aspectFit" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="compare-encourage" wx:if="{{myWorkPath}}">太棒了!每一笔都是进步的证明 💪</text>
|
||||
</view>
|
||||
|
||||
<view style="height:200rpx"></view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<view class="submit-footer">
|
||||
<button
|
||||
class="btn-outline save-btn"
|
||||
bindtap="onSaveRecord"
|
||||
wx:if="{{!saved}}"
|
||||
>保存记录 💾</button>
|
||||
<view class="saved-hint" wx:else>
|
||||
<text>✅ 已保存记录</text>
|
||||
</view>
|
||||
<button class="btn-primary continue-btn" bindtap="onContinueLearning">继续学习</button>
|
||||
</view>
|
||||
</view>
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
.submit-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #f7f8fc;
|
||||
}
|
||||
|
||||
/* 完成横幅 */
|
||||
.complete-banner {
|
||||
background: linear-gradient(135deg, #6C8CFF 0%, #8BA4FF 100%);
|
||||
padding: 40rpx 32rpx 32rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.complete-emoji {
|
||||
font-size: 72rpx;
|
||||
display: block;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
.complete-title {
|
||||
display: block;
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
.complete-sub {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
color: rgba(255,255,255,0.85);
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
/* 视图切换 */
|
||||
.view-switch {
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
border-bottom: 1rpx solid #F0F0F0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.switch-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 24rpx 0;
|
||||
font-size: 28rpx;
|
||||
color: #666666;
|
||||
border-bottom: 4rpx solid transparent;
|
||||
}
|
||||
.switch-item.active {
|
||||
color: #6C8CFF;
|
||||
font-weight: 600;
|
||||
border-bottom-color: #6C8CFF;
|
||||
}
|
||||
|
||||
/* 内容区 */
|
||||
.submit-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.work-display {
|
||||
padding: 32rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.work-frame {
|
||||
width: 100%;
|
||||
height: 500rpx;
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
.example-frame {
|
||||
/* background from style */
|
||||
}
|
||||
.my-frame {
|
||||
background: #F5F5F5;
|
||||
border: 4rpx dashed #CCCCCC;
|
||||
}
|
||||
.work-emoji {
|
||||
font-size: 100rpx;
|
||||
}
|
||||
.work-label {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255,255,255,0.8);
|
||||
}
|
||||
.upload-icon {
|
||||
font-size: 72rpx;
|
||||
}
|
||||
.upload-text {
|
||||
font-size: 28rpx;
|
||||
color: #666666;
|
||||
font-weight: 500;
|
||||
}
|
||||
.upload-hint {
|
||||
font-size: 22rpx;
|
||||
color: #AAAAAA;
|
||||
}
|
||||
.work-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.reupload-hint {
|
||||
position: absolute;
|
||||
bottom: 16rpx;
|
||||
font-size: 22rpx;
|
||||
color: rgba(255,255,255,0.7);
|
||||
background: rgba(0,0,0,0.3);
|
||||
padding: 6rpx 16rpx;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
.work-hint {
|
||||
font-size: 24rpx;
|
||||
color: #AAAAAA;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 对比查看 */
|
||||
.compare-display {
|
||||
padding: 32rpx;
|
||||
}
|
||||
.compare-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.compare-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.compare-label {
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: #888888;
|
||||
font-weight: 500;
|
||||
}
|
||||
.compare-frame {
|
||||
height: 320rpx;
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
.compare-emoji {
|
||||
font-size: 64rpx;
|
||||
}
|
||||
.compare-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.upload-icon-sm {
|
||||
font-size: 48rpx;
|
||||
}
|
||||
.upload-text-sm {
|
||||
font-size: 22rpx;
|
||||
color: #888888;
|
||||
}
|
||||
.compare-divider {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #CCCCCC;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.compare-encourage {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-top: 24rpx;
|
||||
font-size: 28rpx;
|
||||
color: #6C8CFF;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 底部操作 */
|
||||
.submit-footer {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
padding: 20rpx 32rpx;
|
||||
background: #ffffff;
|
||||
border-top: 1rpx solid #F0F0F0;
|
||||
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.save-btn {
|
||||
flex: 1;
|
||||
}
|
||||
.continue-btn {
|
||||
flex: 1;
|
||||
}
|
||||
.saved-hint {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 26rpx;
|
||||
color: #3CB371;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"compileType": "miniprogram",
|
||||
"libVersion": "3.10.1",
|
||||
"packOptions": {
|
||||
"ignore": [],
|
||||
"include": []
|
||||
},
|
||||
"setting": {
|
||||
"coverView": true,
|
||||
"es6": true,
|
||||
"postcss": true,
|
||||
"minified": true,
|
||||
"enhance": true,
|
||||
"showShadowRootInWxmlPanel": true,
|
||||
"packNpmRelationList": [],
|
||||
"babelSetting": {
|
||||
"ignore": [],
|
||||
"disablePlugins": [],
|
||||
"outputPath": ""
|
||||
},
|
||||
"compileWorklet": false,
|
||||
"uglifyFileName": false,
|
||||
"uploadWithSourceMap": true,
|
||||
"packNpmManually": false,
|
||||
"minifyWXSS": true,
|
||||
"minifyWXML": true,
|
||||
"localPlugins": false,
|
||||
"condition": false,
|
||||
"swc": false,
|
||||
"disableSWC": true,
|
||||
"disableUseStrict": false,
|
||||
"useCompilerPlugins": false
|
||||
},
|
||||
"condition": {},
|
||||
"editorSetting": {
|
||||
"tabIndent": "auto",
|
||||
"tabSize": 2
|
||||
},
|
||||
"appid": "wxe4ef1cc6e75de032",
|
||||
"simulatorPluginLibVersion": {}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档:https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html",
|
||||
"projectname": "miniapp-huahuazenmehua",
|
||||
"setting": {
|
||||
"compileHotReLoad": true,
|
||||
"urlCheck": false,
|
||||
"coverView": true,
|
||||
"lazyloadPlaceholderEnable": false,
|
||||
"skylineRenderEnable": false,
|
||||
"preloadBackgroundData": false,
|
||||
"autoAudits": false,
|
||||
"useApiHook": true,
|
||||
"useApiHostProcess": true,
|
||||
"showShadowRootInWxmlPanel": true,
|
||||
"useStaticServer": false,
|
||||
"useLanDebug": false,
|
||||
"showES6CompileOption": false,
|
||||
"bigPackageSizeSupport": false,
|
||||
"checkInvalidKey": true,
|
||||
"ignoreDevUnusedFiles": true
|
||||
},
|
||||
"libVersion": "3.10.1",
|
||||
"condition": {}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html",
|
||||
"rules": [{
|
||||
"action": "allow",
|
||||
"page": "*"
|
||||
}]
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* API 接口定义
|
||||
* 画画怎么画 — 纯本地存储项目,无后端 API 调用
|
||||
* 所有数据操作通过 utils/storage.js 进行
|
||||
*/
|
||||
|
||||
// 本项目为本地存储型工具,所有接口通过 storage.js 实现
|
||||
// 如后续需要接入后端,在此统一定义接口
|
||||
|
||||
module.exports = {}
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
/**
|
||||
* 用户认证工具模块
|
||||
* 统一管理登录状态检查、登录跳转等
|
||||
*/
|
||||
|
||||
/**
|
||||
* 检查用户是否已登录
|
||||
* @returns {Boolean} 是否已登录
|
||||
*/
|
||||
function isLoggedIn() {
|
||||
const userId = wx.getStorageSync('userId')
|
||||
const token = wx.getStorageSync('token')
|
||||
return !!(userId && token)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
* @returns {Object|null} 用户信息对象或null
|
||||
*/
|
||||
function getUserInfo() {
|
||||
if (!isLoggedIn()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const userInfo = wx.getStorageSync('userInfo')
|
||||
return userInfo || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户ID
|
||||
* @returns {String|null} 用户ID或null
|
||||
*/
|
||||
function getUserId() {
|
||||
return wx.getStorageSync('userId') || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Token
|
||||
* @returns {String|null} Token或null
|
||||
*/
|
||||
function getToken() {
|
||||
return wx.getStorageSync('token') || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查登录状态,未登录则跳转登录页
|
||||
* @param {Object} options 配置项
|
||||
* @param {String} options.returnUrl 登录成功后返回的页面URL
|
||||
* @param {String} options.returnType 返回类型: navigateTo/redirectTo/switchTab
|
||||
* @param {Function} options.success 已登录时的回调
|
||||
* @param {Function} options.fail 未登录时的回调
|
||||
* @returns {Boolean} 是否已登录
|
||||
*/
|
||||
function checkLogin(options = {}) {
|
||||
const {
|
||||
returnUrl = '',
|
||||
returnType = 'navigateTo',
|
||||
success,
|
||||
fail
|
||||
} = options
|
||||
|
||||
if (isLoggedIn()) {
|
||||
// 已登录
|
||||
success && success()
|
||||
return true
|
||||
} else {
|
||||
// 未登录,跳转登录页
|
||||
fail && fail()
|
||||
|
||||
// 保存返回信息
|
||||
if (returnUrl) {
|
||||
wx.setStorageSync('returnTo', {
|
||||
url: returnUrl,
|
||||
type: returnType
|
||||
})
|
||||
}
|
||||
|
||||
wx.navigateTo({
|
||||
url: '/pages/login/login'
|
||||
})
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 要求登录后执行操作
|
||||
* @param {Function} callback 登录后执行的回调
|
||||
* @param {Object} options 配置项
|
||||
*/
|
||||
function requireLogin(callback, options = {}) {
|
||||
return checkLogin({
|
||||
...options,
|
||||
success: callback,
|
||||
fail: () => {
|
||||
wx.showToast({
|
||||
title: '请先登录',
|
||||
icon: 'none',
|
||||
duration: 2000
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存用户信息到本地
|
||||
* @param {Object} data 用户数据
|
||||
* @param {String} data.userId 用户ID
|
||||
* @param {String} data.token Token
|
||||
* @param {Object} data.userInfo 用户信息
|
||||
*/
|
||||
function saveUserData(data) {
|
||||
const { userId, token, userInfo } = data
|
||||
|
||||
if (userId) {
|
||||
wx.setStorageSync('userId', userId)
|
||||
}
|
||||
|
||||
if (token) {
|
||||
wx.setStorageSync('token', token)
|
||||
}
|
||||
|
||||
if (userInfo) {
|
||||
wx.setStorageSync('userInfo', userInfo)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除用户登录信息
|
||||
*/
|
||||
function clearUserData() {
|
||||
wx.removeStorageSync('userId')
|
||||
wx.removeStorageSync('token')
|
||||
wx.removeStorageSync('userInfo')
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到登录页面(带返回功能)
|
||||
* 参考 checkin.js 的实现,显示弹窗确认后跳转登录页
|
||||
* @param {Object} options 配置项
|
||||
* @param {String} options.message 提示信息
|
||||
* @param {String} options.returnUrl 登录成功后返回的页面URL(不传则自动获取当前页面)
|
||||
* @param {String} options.returnType 返回类型: navigateTo/redirectTo/switchTab
|
||||
* @param {Boolean} options.showCancel 是否显示取消按钮
|
||||
* @param {Function} options.onCancel 取消回调
|
||||
*/
|
||||
function navigateToLogin(options = {}) {
|
||||
const {
|
||||
message = '请先登录',
|
||||
returnUrl = '',
|
||||
returnType = 'navigateTo',
|
||||
showCancel = true,
|
||||
onCancel = null
|
||||
} = options
|
||||
|
||||
// 自动获取当前页面路径
|
||||
let finalReturnUrl = returnUrl
|
||||
if (!finalReturnUrl) {
|
||||
const pages = getCurrentPages()
|
||||
if (pages.length > 0) {
|
||||
const currentPage = pages[pages.length - 1]
|
||||
finalReturnUrl = '/' + currentPage.route
|
||||
// 添加页面参数
|
||||
if (currentPage.options && Object.keys(currentPage.options).length > 0) {
|
||||
const params = Object.keys(currentPage.options)
|
||||
.map(key => `${key}=${currentPage.options[key]}`)
|
||||
.join('&')
|
||||
finalReturnUrl += '?' + params
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wx.showModal({
|
||||
title: '提示',
|
||||
content: message,
|
||||
confirmText: '去登录',
|
||||
cancelText: '返回',
|
||||
showCancel: showCancel,
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
// 保存返回信息,登录成功后返回当前页面
|
||||
if (finalReturnUrl) {
|
||||
wx.setStorageSync('returnTo', {
|
||||
type: returnType,
|
||||
url: finalReturnUrl
|
||||
})
|
||||
}
|
||||
wx.redirectTo({
|
||||
url: '/pages/login/login'
|
||||
})
|
||||
} else {
|
||||
// 点击取消
|
||||
if (onCancel) {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录
|
||||
* @param {Object} options 配置项
|
||||
* @param {String} options.redirectUrl 退出后跳转的页面
|
||||
* @param {Boolean} options.confirm 是否需要确认
|
||||
* @param {Function} options.success 成功回调
|
||||
*/
|
||||
function logout(options = {}) {
|
||||
const {
|
||||
redirectUrl = '/pages/home/home',
|
||||
confirm = true,
|
||||
success
|
||||
} = options
|
||||
|
||||
const doLogout = () => {
|
||||
clearUserData()
|
||||
|
||||
wx.showToast({
|
||||
title: '已退出登录',
|
||||
icon: 'success',
|
||||
duration: 1500
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
success && success()
|
||||
|
||||
// 跳转到指定页面
|
||||
if (redirectUrl.startsWith('/pages/home/') ||
|
||||
redirectUrl.startsWith('/pages/index/') ||
|
||||
redirectUrl.startsWith('/pages/record/') ||
|
||||
redirectUrl.startsWith('/pages/settings/')) {
|
||||
wx.reLaunch({
|
||||
url: redirectUrl
|
||||
})
|
||||
} else {
|
||||
wx.redirectTo({
|
||||
url: redirectUrl
|
||||
})
|
||||
}
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
if (confirm) {
|
||||
wx.showModal({
|
||||
title: '退出登录',
|
||||
content: '确定要退出登录吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
doLogout()
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
doLogout()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isLoggedIn,
|
||||
getUserInfo,
|
||||
getUserId,
|
||||
getToken,
|
||||
checkLogin,
|
||||
requireLogin,
|
||||
navigateToLogin,
|
||||
saveUserData,
|
||||
clearUserData,
|
||||
logout
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* API 配置文件
|
||||
* 统一管理开发环境和生产环境的配置
|
||||
*/
|
||||
|
||||
// 开发模式开关
|
||||
const IS_DEV = false
|
||||
|
||||
// 开发环境配置
|
||||
const DEV_CONFIG = {
|
||||
apiBase: 'http://localhost:3001/api',
|
||||
timeout: 30000,
|
||||
enableLog: true
|
||||
}
|
||||
|
||||
// 生产环境配置
|
||||
const PROD_CONFIG = {
|
||||
apiBase: '/mp/jd-huahuazenmehua', // 模块名
|
||||
timeout: 30000,
|
||||
enableLog: false
|
||||
}
|
||||
|
||||
// 当前环境配置
|
||||
const CONFIG = IS_DEV ? DEV_CONFIG : PROD_CONFIG
|
||||
|
||||
module.exports = {
|
||||
IS_DEV,
|
||||
API_BASE: CONFIG.apiBase,
|
||||
TIMEOUT: CONFIG.timeout,
|
||||
ENABLE_LOG: CONFIG.enableLog,
|
||||
|
||||
// 切换环境方法(用于调试)
|
||||
switchEnv: (isDev) => {
|
||||
return isDev ? DEV_CONFIG : PROD_CONFIG
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,644 @@
|
|||
/**
|
||||
* 静态课程数据
|
||||
*/
|
||||
|
||||
// 入门路径卡片
|
||||
const BEGINNER_PATH = [
|
||||
{
|
||||
id: 'path_1',
|
||||
title: '线条练习',
|
||||
desc: '学会控笔,从直线到曲线',
|
||||
icon: '✏️',
|
||||
color: '#6C8CFF',
|
||||
courseId: 'c_001'
|
||||
},
|
||||
{
|
||||
id: 'path_2',
|
||||
title: '形状组合',
|
||||
desc: '圆形、三角形、方形的变换',
|
||||
icon: '⬡',
|
||||
color: '#FFB84D',
|
||||
courseId: 'c_002'
|
||||
},
|
||||
{
|
||||
id: 'path_3',
|
||||
title: '临摹入门',
|
||||
desc: '跟着范例一步步临摹',
|
||||
icon: '🖼',
|
||||
color: '#6CE5A0',
|
||||
courseId: 'c_003'
|
||||
},
|
||||
{
|
||||
id: 'path_4',
|
||||
title: '简单上色',
|
||||
desc: '认识颜色,学会基础涂色',
|
||||
icon: '🎨',
|
||||
color: '#FF7B7B',
|
||||
courseId: 'c_004'
|
||||
}
|
||||
]
|
||||
|
||||
// 课程分类
|
||||
const CATEGORIES = ['简笔画', '人物', '动物', '植物', '风景', '素描基础']
|
||||
|
||||
// 所有课程数据
|
||||
const ALL_COURSES = [
|
||||
// 简笔画
|
||||
{
|
||||
id: 'c_001',
|
||||
category: '简笔画',
|
||||
title: '直线与曲线基础',
|
||||
cover: '',
|
||||
coverColor: '#6C8CFF',
|
||||
coverEmoji: '✏️',
|
||||
desc: '掌握控笔基础,学会画出流畅的直线和曲线,是一切绘画的起点。',
|
||||
difficulty: '零基础',
|
||||
lessons: 5,
|
||||
duration: '15分钟',
|
||||
target: '能独立画出均匀流畅的线条',
|
||||
suitable: '完全没有绘画经验的初学者',
|
||||
tools: '铅笔、白纸',
|
||||
steps: [
|
||||
{
|
||||
title: '准备工具',
|
||||
image: '',
|
||||
imageEmoji: '📝',
|
||||
desc: '准备好一支HB铅笔和一张白纸。握笔时手腕放松,不要用力捏住铅笔。',
|
||||
tip: '💡 手腕放松是画好线条的关键'
|
||||
},
|
||||
{
|
||||
title: '画水平直线',
|
||||
image: '',
|
||||
imageEmoji: '➖',
|
||||
desc: '从左到右,匀速画出一条水平直线。注意力度均匀,不要停顿。多练习几组,间距保持一致。',
|
||||
tip: '💡 眼睛看终点,手跟着眼走'
|
||||
},
|
||||
{
|
||||
title: '画垂直直线',
|
||||
image: '',
|
||||
imageEmoji: '|',
|
||||
desc: '从上往下,画出垂直直线。可以在纸上先标出起点和终点,帮助对齐方向。',
|
||||
tip: '💡 不要一次画很长,先从短线练起'
|
||||
},
|
||||
{
|
||||
title: '画弧线',
|
||||
image: '',
|
||||
imageEmoji: '⌒',
|
||||
desc: '以肘关节为轴,画出圆滑的弧线。弧线要圆润,不能有折点。',
|
||||
tip: '💡 弧线靠手臂运动,不是手腕扭动'
|
||||
},
|
||||
{
|
||||
title: '综合练习',
|
||||
image: '',
|
||||
imageEmoji: '🌊',
|
||||
desc: '结合直线和弧线,画出波浪形线条。这是检验你控笔能力的最好方式。',
|
||||
tip: '💡 每天练习5分钟,一周后你会明显进步'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'c_002',
|
||||
category: '简笔画',
|
||||
title: '基础形状练习',
|
||||
cover: '',
|
||||
coverColor: '#FFB84D',
|
||||
coverEmoji: '⬡',
|
||||
desc: '从圆形、三角形、方形出发,学会用简单形状组合出各种物体。',
|
||||
difficulty: '零基础',
|
||||
lessons: 4,
|
||||
duration: '20分钟',
|
||||
target: '能用基础形状拼出简单图案',
|
||||
suitable: '练习过线条的初学者',
|
||||
tools: '铅笔、橡皮、白纸',
|
||||
steps: [
|
||||
{
|
||||
title: '画正圆',
|
||||
image: '',
|
||||
imageEmoji: '⭕',
|
||||
desc: '用手腕转动的方式画圆,一笔完成。先画小圆练手感,再逐渐加大。',
|
||||
tip: '💡 可以用硬币辅助,熟悉圆的弧度感'
|
||||
},
|
||||
{
|
||||
title: '画正三角形',
|
||||
image: '',
|
||||
imageEmoji: '△',
|
||||
desc: '先画底边,再从两端分别向上延伸,在顶点汇合。注意三条边长度要接近。',
|
||||
tip: '💡 先轻轻画,满意后再加重'
|
||||
},
|
||||
{
|
||||
title: '画正方形',
|
||||
image: '',
|
||||
imageEmoji: '⬜',
|
||||
desc: '四条边依次画出,转角处要成直角。可以先画一条参考线保证水平。',
|
||||
tip: '💡 四个角要90度,不然看起来会歪'
|
||||
},
|
||||
{
|
||||
title: '形状组合练习',
|
||||
image: '',
|
||||
imageEmoji: '🏠',
|
||||
desc: '用正方形和三角形组合出一栋小房子。圆形变成太阳,长方形变成门。',
|
||||
tip: '💡 这就是简笔画的基本原理——形状组合'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'c_003',
|
||||
category: '简笔画',
|
||||
title: '临摹入门:太阳花',
|
||||
cover: '',
|
||||
coverColor: '#FFE566',
|
||||
coverEmoji: '🌻',
|
||||
desc: '跟着步骤一步步临摹一朵向日葵,体验从无到有的成就感。',
|
||||
difficulty: '入门',
|
||||
lessons: 6,
|
||||
duration: '25分钟',
|
||||
target: '完成一幅向日葵简笔画',
|
||||
suitable: '已掌握基础形状的初学者',
|
||||
tools: '铅笔、彩色笔、白纸',
|
||||
steps: [
|
||||
{
|
||||
title: '画花心圆',
|
||||
image: '',
|
||||
imageEmoji: '⭕',
|
||||
desc: '在纸张中央画一个中等大小的圆形,这是向日葵的花心。',
|
||||
tip: '💡 花心不用画太大,留出空间给花瓣'
|
||||
},
|
||||
{
|
||||
title: '添加花瓣',
|
||||
image: '',
|
||||
imageEmoji: '🌼',
|
||||
desc: '围绕花心,均匀画出12-16片椭圆形花瓣。每片花瓣从花心边缘向外延伸。',
|
||||
tip: '💡 花瓣间距均匀,看起来更好看'
|
||||
},
|
||||
{
|
||||
title: '画茎和叶',
|
||||
image: '',
|
||||
imageEmoji: '🌿',
|
||||
desc: '从花心底部画一条向下弯曲的茎,两侧加上心形的叶片。',
|
||||
tip: '💡 茎可以略微弯曲,更自然'
|
||||
},
|
||||
{
|
||||
title: '添加细节',
|
||||
image: '',
|
||||
imageEmoji: '✨',
|
||||
desc: '在花心内部画出小格子纹理,花瓣上添加几条纹路线。',
|
||||
tip: '💡 细节不用太多,点到为止'
|
||||
},
|
||||
{
|
||||
title: '上色:花瓣',
|
||||
image: '',
|
||||
imageEmoji: '🟡',
|
||||
desc: '用黄色给花瓣上色,从花瓣根部向外涂,注意留白产生光泽感。',
|
||||
tip: '💡 涂色方向统一,颜色更均匀'
|
||||
},
|
||||
{
|
||||
title: '上色:完成',
|
||||
image: '',
|
||||
imageEmoji: '🌻',
|
||||
desc: '花心涂深棕色,茎叶涂绿色。完成!',
|
||||
tip: '💡 恭喜你完成了第一幅作品!'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'c_004',
|
||||
category: '简笔画',
|
||||
title: '简单上色技法',
|
||||
cover: '',
|
||||
coverColor: '#FF7B7B',
|
||||
coverEmoji: '🎨',
|
||||
desc: '学习基础上色方法,让你的画作变得生动有色彩。',
|
||||
difficulty: '入门',
|
||||
lessons: 4,
|
||||
duration: '20分钟',
|
||||
target: '掌握平涂、渐变两种基本上色技法',
|
||||
suitable: '完成线稿练习的初学者',
|
||||
tools: '彩色笔或蜡笔、白纸',
|
||||
steps: [
|
||||
{
|
||||
title: '认识颜色',
|
||||
image: '',
|
||||
imageEmoji: '🌈',
|
||||
desc: '三原色:红、黄、蓝。它们两两混合产生橙、绿、紫。了解颜色的基本关系。',
|
||||
tip: '💡 先从最常用的几个颜色开始'
|
||||
},
|
||||
{
|
||||
title: '平涂练习',
|
||||
image: '',
|
||||
imageEmoji: '▪️',
|
||||
desc: '在一个正方形内,用彩笔均匀平涂。涂色方向保持一致(全部横向或全部竖向)。',
|
||||
tip: '💡 用力均匀,才能颜色均匀'
|
||||
},
|
||||
{
|
||||
title: '渐变上色',
|
||||
image: '',
|
||||
imageEmoji: '🌅',
|
||||
desc: '从一侧开始用力涂,向另一侧逐渐减轻力度,产生由深到浅的渐变效果。',
|
||||
tip: '💡 渐变让画面更有立体感'
|
||||
},
|
||||
{
|
||||
title: '给简笔画上色',
|
||||
image: '',
|
||||
imageEmoji: '🍎',
|
||||
desc: '用平涂技法给一个苹果线稿上色:主体红色,顶部留白显光泽,底部稍深。',
|
||||
tip: '💡 留白是让画看起来有光感的秘诀'
|
||||
}
|
||||
]
|
||||
},
|
||||
// 动物
|
||||
{
|
||||
id: 'c_005',
|
||||
category: '动物',
|
||||
title: '可爱小猫咪',
|
||||
cover: '',
|
||||
coverColor: '#FFB84D',
|
||||
coverEmoji: '🐱',
|
||||
desc: '用简单的几何形状画出一只萌萌的小猫,适合零基础入门。',
|
||||
difficulty: '入门',
|
||||
lessons: 5,
|
||||
duration: '20分钟',
|
||||
target: '完成一幅小猫简笔画',
|
||||
suitable: '喜欢动物的初学者',
|
||||
tools: '铅笔、黑色勾线笔、彩色笔',
|
||||
steps: [
|
||||
{
|
||||
title: '画猫头',
|
||||
image: '',
|
||||
imageEmoji: '⭕',
|
||||
desc: '画一个稍大的圆形作为猫的头部,上方两侧各加一个小三角形作为耳朵。',
|
||||
tip: '💡 耳朵角度向外微微张开,更可爱'
|
||||
},
|
||||
{
|
||||
title: '画五官',
|
||||
image: '',
|
||||
imageEmoji: '👁',
|
||||
desc: '画两个大圆眼睛,中间画小椭圆瞳孔。鼻子是小三角形,嘴巴是W形。',
|
||||
tip: '💡 眼睛大一些,猫咪看起来更萌'
|
||||
},
|
||||
{
|
||||
title: '画胡须',
|
||||
image: '',
|
||||
imageEmoji: '~',
|
||||
desc: '鼻子两侧各画3根细长的胡须线,要画得自然弯曲。',
|
||||
tip: '💡 胡须是猫咪最有特色的部分'
|
||||
},
|
||||
{
|
||||
title: '画身体',
|
||||
image: '',
|
||||
imageEmoji: '🐾',
|
||||
desc: '头部下方画一个椭圆形身体,加上前后四条腿和一条弯曲的尾巴。',
|
||||
tip: '💡 尾巴末端可以卷起来,更生动'
|
||||
},
|
||||
{
|
||||
title: '上色完成',
|
||||
image: '',
|
||||
imageEmoji: '🐱',
|
||||
desc: '用橙色或灰色给猫咪上色,耳朵内侧涂粉色,加上几条条纹斑纹。',
|
||||
tip: '💡 完成!你的第一只猫咪诞生了'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'c_006',
|
||||
category: '动物',
|
||||
title: '萌萌小兔子',
|
||||
cover: '',
|
||||
coverColor: '#FF9EC4',
|
||||
coverEmoji: '🐰',
|
||||
desc: '画出一只长耳朵可爱兔子,学习动物五官的表达方式。',
|
||||
difficulty: '入门',
|
||||
lessons: 4,
|
||||
duration: '15分钟',
|
||||
target: '完成一幅兔子简笔画',
|
||||
suitable: '已有初步控笔能力的初学者',
|
||||
tools: '铅笔、彩色笔',
|
||||
steps: [
|
||||
{
|
||||
title: '画兔子头和耳朵',
|
||||
image: '',
|
||||
imageEmoji: '🐰',
|
||||
desc: '画圆形头部,顶部画两个细长的竖耳朵(椭圆形),耳朵比头稍长。',
|
||||
tip: '💡 长耳朵是兔子最显眼的特征'
|
||||
},
|
||||
{
|
||||
title: '画五官',
|
||||
image: '',
|
||||
imageEmoji: '👀',
|
||||
desc: '两个圆眼睛,小圆鼻子,嘴巴是"Y"形(两片嘴唇)。腮部加两个小圆圈。',
|
||||
tip: '💡 腮红让兔子更可爱'
|
||||
},
|
||||
{
|
||||
title: '画身体',
|
||||
image: '',
|
||||
imageEmoji: '🫁',
|
||||
desc: '圆润的椭圆形身体,短短的四肢,背面有一个小圆尾巴。',
|
||||
tip: '💡 兔子身体圆润,不要画得太尖'
|
||||
},
|
||||
{
|
||||
title: '上色完成',
|
||||
image: '',
|
||||
imageEmoji: '🐇',
|
||||
desc: '白色兔子留白,耳朵内侧和腮红涂粉色,眼睛可以涂红色或蓝色。',
|
||||
tip: '💡 白色兔子的轮廓线用浅灰色更好看'
|
||||
}
|
||||
]
|
||||
},
|
||||
// 植物
|
||||
{
|
||||
id: 'c_007',
|
||||
category: '植物',
|
||||
title: '多肉植物',
|
||||
cover: '',
|
||||
coverColor: '#6CE5A0',
|
||||
coverEmoji: '🪴',
|
||||
desc: '画出可爱的多肉植物,学习植物形态的表达和重叠关系。',
|
||||
difficulty: '入门',
|
||||
lessons: 5,
|
||||
duration: '20分钟',
|
||||
target: '完成一盆多肉植物图案',
|
||||
suitable: '喜欢植物的初学者',
|
||||
tools: '铅笔、绿色系彩笔',
|
||||
steps: [
|
||||
{
|
||||
title: '画花盆',
|
||||
image: '',
|
||||
imageEmoji: '🪣',
|
||||
desc: '画一个梯形花盆:上宽下窄,底部加一条横线表示盆底,两侧弧度自然。',
|
||||
tip: '💡 花盆大小要和上方植物匹配'
|
||||
},
|
||||
{
|
||||
title: '画中心叶片',
|
||||
image: '',
|
||||
imageEmoji: '🌿',
|
||||
desc: '在花盆上方中央画一片椭圆形叶片,尖端向上,这是多肉的最顶部。',
|
||||
tip: '💡 叶片要厚实饱满,多肉的特征'
|
||||
},
|
||||
{
|
||||
title: '添加外层叶片',
|
||||
image: '',
|
||||
imageEmoji: '🍃',
|
||||
desc: '围绕中心叶片,向外交错排列6-8片叶片,越外层越大越向外张开。',
|
||||
tip: '💡 叶片之间稍微重叠,有层次感'
|
||||
},
|
||||
{
|
||||
title: '添加细节',
|
||||
image: '',
|
||||
imageEmoji: '✨',
|
||||
desc: '每片叶片中间画一条中脉,花盆上画几条纹路。',
|
||||
tip: '💡 细节不用多,一两条线就够了'
|
||||
},
|
||||
{
|
||||
title: '上色完成',
|
||||
image: '',
|
||||
imageEmoji: '🪴',
|
||||
desc: '叶片涂绿色,叶尖可以点一点红色或紫色(多肉晒红的效果),花盆涂浅棕色。',
|
||||
tip: '💡 多肉叶尖的颜色变化是亮点'
|
||||
}
|
||||
]
|
||||
},
|
||||
// 人物
|
||||
{
|
||||
id: 'c_008',
|
||||
category: '人物',
|
||||
title: '简笔小人基础',
|
||||
cover: '',
|
||||
coverColor: '#A78BFA',
|
||||
coverEmoji: '🧍',
|
||||
desc: '学会画一个基础的简笔小人,掌握人体比例关系。',
|
||||
difficulty: '入门',
|
||||
lessons: 5,
|
||||
duration: '20分钟',
|
||||
target: '画出比例协调的简笔小人',
|
||||
suitable: '想学画人物的初学者',
|
||||
tools: '铅笔、彩色笔',
|
||||
steps: [
|
||||
{
|
||||
title: '画头部',
|
||||
image: '',
|
||||
imageEmoji: '😶',
|
||||
desc: '画一个圆形头部,大小适中。简笔画中头部约占全身的1/6。',
|
||||
tip: '💡 先确定好头的大小,其他部分按比例来'
|
||||
},
|
||||
{
|
||||
title: '画躯干',
|
||||
image: '',
|
||||
imageEmoji: '🫀',
|
||||
desc: '从脖子向下画一个长方形躯干,高度约为头的2倍。肩部略宽,腰部略窄。',
|
||||
tip: '💡 躯干是人体的核心,要画得端正'
|
||||
},
|
||||
{
|
||||
title: '画手臂',
|
||||
image: '',
|
||||
imageEmoji: '💪',
|
||||
desc: '从肩部向下画两条手臂,末端加上简单的手形(可以是手套形)。',
|
||||
tip: '💡 手臂自然下垂时,手腕在腰部左右'
|
||||
},
|
||||
{
|
||||
title: '画腿和脚',
|
||||
image: '',
|
||||
imageEmoji: '🦵',
|
||||
desc: '从腰部向下画两条腿,略比手臂粗。末端画简单的椭圆形鞋子。',
|
||||
tip: '💡 腿的长度约为躯干的1.5倍'
|
||||
},
|
||||
{
|
||||
title: '添加五官和服装',
|
||||
image: '',
|
||||
imageEmoji: '🧍',
|
||||
desc: '给头部添加简单五官,躯干部分画上衬衫领口和口袋等细节。',
|
||||
tip: '💡 简笔小人不必精细,可爱就够了'
|
||||
}
|
||||
]
|
||||
},
|
||||
// 风景
|
||||
{
|
||||
id: 'c_009',
|
||||
category: '风景',
|
||||
title: '简单风景:晴天',
|
||||
cover: '',
|
||||
coverColor: '#87CEEB',
|
||||
coverEmoji: '🌤',
|
||||
desc: '画出一幅包含天空、山丘和草地的简单风景,学习风景构图基础。',
|
||||
difficulty: '入门',
|
||||
lessons: 5,
|
||||
duration: '25分钟',
|
||||
target: '完成一幅简单的晴天风景画',
|
||||
suitable: '想学风景画的初学者',
|
||||
tools: '铅笔、彩色笔或蜡笔',
|
||||
steps: [
|
||||
{
|
||||
title: '画地平线',
|
||||
image: '',
|
||||
imageEmoji: '➖',
|
||||
desc: '在纸张约2/3高度处画一条水平线作为地平线,上方是天空,下方是地面。',
|
||||
tip: '💡 地平线的高低决定了画面的空间感'
|
||||
},
|
||||
{
|
||||
title: '画远山',
|
||||
image: '',
|
||||
imageEmoji: '⛰',
|
||||
desc: '在地平线上方画几个大小不一的弧形山丘,前后叠加产生远近感。',
|
||||
tip: '💡 远处的山要画得小一些、颜色淡一些'
|
||||
},
|
||||
{
|
||||
title: '画太阳和云',
|
||||
image: '',
|
||||
imageEmoji: '☀️',
|
||||
desc: '右上角画一个圆形太阳,周围加短线条表示光芒。画几朵简单的棉花云。',
|
||||
tip: '💡 云的形状:多个小圆形叠在一起'
|
||||
},
|
||||
{
|
||||
title: '画草地和树',
|
||||
image: '',
|
||||
imageEmoji: '🌲',
|
||||
desc: '地平线下方涂绿色草地,加几棵三角形松树和圆形树冠的树。',
|
||||
tip: '💡 树的大小和远近要有变化'
|
||||
},
|
||||
{
|
||||
title: '上色完成',
|
||||
image: '',
|
||||
imageEmoji: '🌄',
|
||||
desc: '天空涂浅蓝色,山丘涂蓝绿色,草地涂绿色,太阳涂黄色。',
|
||||
tip: '💡 颜色可以从浅到深,层次更丰富'
|
||||
}
|
||||
]
|
||||
},
|
||||
// 素描基础
|
||||
{
|
||||
id: 'c_010',
|
||||
category: '素描基础',
|
||||
title: '排线入门',
|
||||
cover: '',
|
||||
coverColor: '#888888',
|
||||
coverEmoji: '📐',
|
||||
desc: '学习素描最基础的排线技法,这是素描的核心功夫。',
|
||||
difficulty: '入门',
|
||||
lessons: 4,
|
||||
duration: '20分钟',
|
||||
target: '掌握均匀排线的基本方法',
|
||||
suitable: '想系统学习素描的初学者',
|
||||
tools: 'HB/2B铅笔、素描纸',
|
||||
steps: [
|
||||
{
|
||||
title: '认识铅笔硬度',
|
||||
image: '',
|
||||
imageEmoji: '✏️',
|
||||
desc: 'H系列越硬线条越细浅,B系列越软线条越粗深。初学者用HB或2B最合适。',
|
||||
tip: '💡 同一支笔,用力不同也能画出深浅变化'
|
||||
},
|
||||
{
|
||||
title: '单向排线练习',
|
||||
image: '',
|
||||
imageEmoji: '|||',
|
||||
desc: '均匀画出平行的斜线,线条之间间距相等,粗细相同。从左到右,不回笔。',
|
||||
tip: '💡 排线要平行,不能交叉弯曲'
|
||||
},
|
||||
{
|
||||
title: '交叉排线',
|
||||
image: '',
|
||||
imageEmoji: '###',
|
||||
desc: '在第一层排线上,换一个角度叠加第二层排线,形成交叉网格效果。',
|
||||
tip: '💡 交叉排线可以制造丰富的明暗层次'
|
||||
},
|
||||
{
|
||||
title: '渐变调子',
|
||||
image: '',
|
||||
imageEmoji: '▓',
|
||||
desc: '用排线的疏密变化制造渐变:左侧排线密(颜色深),向右逐渐变疏(颜色浅)。',
|
||||
tip: '💡 素描的光影就是靠排线疏密来表现的'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
// 今日推荐(取前3个)
|
||||
const DAILY_RECOMMEND = ALL_COURSES.slice(0, 3)
|
||||
|
||||
// 练习任务数据
|
||||
const PRACTICE_TASKS = [
|
||||
{
|
||||
id: 'pt_001',
|
||||
title: '直线描线练习',
|
||||
type: '描线练习',
|
||||
difficulty: '入门',
|
||||
duration: '5分钟',
|
||||
courseId: 'c_001',
|
||||
stepIndex: 1,
|
||||
icon: '➖',
|
||||
iconColor: '#6C8CFF',
|
||||
desc: '画100条均匀的水平直线,感受控笔节奏'
|
||||
},
|
||||
{
|
||||
id: 'pt_002',
|
||||
title: '圆形临摹练习',
|
||||
type: '形状练习',
|
||||
difficulty: '入门',
|
||||
duration: '5分钟',
|
||||
courseId: 'c_002',
|
||||
stepIndex: 0,
|
||||
icon: '⭕',
|
||||
iconColor: '#FFB84D',
|
||||
desc: '连续画50个大小不一的圆形,提升圆弧控制能力'
|
||||
},
|
||||
{
|
||||
id: 'pt_003',
|
||||
title: '向日葵临摹',
|
||||
type: '临摹练习',
|
||||
difficulty: '初级',
|
||||
duration: '15分钟',
|
||||
courseId: 'c_003',
|
||||
stepIndex: 0,
|
||||
icon: '🌻',
|
||||
iconColor: '#FFE566',
|
||||
desc: '完整临摹一朵向日葵,综合线条和形状能力'
|
||||
},
|
||||
{
|
||||
id: 'pt_004',
|
||||
title: '小猫描线',
|
||||
type: '描线练习',
|
||||
difficulty: '初级',
|
||||
duration: '10分钟',
|
||||
courseId: 'c_005',
|
||||
stepIndex: 0,
|
||||
icon: '🐱',
|
||||
iconColor: '#FFB84D',
|
||||
desc: '对照范例,描出小猫的轮廓线'
|
||||
},
|
||||
{
|
||||
id: 'pt_005',
|
||||
title: '多肉形状组合',
|
||||
type: '形状练习',
|
||||
difficulty: '初级',
|
||||
duration: '10分钟',
|
||||
courseId: 'c_007',
|
||||
stepIndex: 1,
|
||||
icon: '🪴',
|
||||
iconColor: '#6CE5A0',
|
||||
desc: '用椭圆形叶片组合画出多肉植物'
|
||||
},
|
||||
{
|
||||
id: 'pt_006',
|
||||
title: '素描排线',
|
||||
type: '描线练习',
|
||||
difficulty: '初级',
|
||||
duration: '10分钟',
|
||||
courseId: 'c_010',
|
||||
stepIndex: 1,
|
||||
icon: '📐',
|
||||
iconColor: '#888888',
|
||||
desc: '完成单向排线和交叉排线各一组'
|
||||
}
|
||||
]
|
||||
|
||||
module.exports = {
|
||||
BEGINNER_PATH,
|
||||
CATEGORIES,
|
||||
ALL_COURSES,
|
||||
DAILY_RECOMMEND,
|
||||
PRACTICE_TASKS,
|
||||
getCourseById(id) {
|
||||
return ALL_COURSES.find(c => c.id === id) || null
|
||||
},
|
||||
getCoursesByCategory(category) {
|
||||
return ALL_COURSES.filter(c => c.category === category)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* 统一的 HTTP 客户端
|
||||
* 自动根据环境切换使用 gatewayHttpClient 或 HttpClient
|
||||
*/
|
||||
|
||||
import { gatewayHttpClient, HttpClient } from '@jdmini/api'
|
||||
const { IS_DEV, API_BASE } = require('./config.js')
|
||||
|
||||
// 开发环境使用 HttpClient
|
||||
const devHttpClient = IS_DEV ? new HttpClient({
|
||||
baseURL: API_BASE,
|
||||
timeout: 30000,
|
||||
}) : null
|
||||
|
||||
/**
|
||||
* 统一的 HTTP 请求方法
|
||||
* @param {String} url - 请求路径(相对路径,不包含 baseURL)
|
||||
* @param {String} method - 请求方法 GET/POST/PUT/DELETE
|
||||
* @param {Object} data - 请求数据
|
||||
* @param {Object} options - 额外选项
|
||||
* @returns {Promise}
|
||||
*/
|
||||
function request(url, method = 'GET', data = {}, options = {}) {
|
||||
// 确保 URL 以 / 开头
|
||||
const apiUrl = url.startsWith('/') ? url : `/${url}`
|
||||
|
||||
if (IS_DEV) {
|
||||
// 开发环境:使用 HttpClient
|
||||
console.log(`[DEV] ${method} ${API_BASE}${apiUrl}`, data)
|
||||
return devHttpClient.request(apiUrl, method, data, options)
|
||||
} else {
|
||||
// 生产环境:使用 gatewayHttpClient
|
||||
// gatewayHttpClient 需要完整的路径:mp/jd-haiba/user/login
|
||||
const fullUrl = `${API_BASE}${apiUrl}`
|
||||
console.log(`[PROD] ${method} ${fullUrl}`, data)
|
||||
|
||||
// gatewayHttpClient.request 的参数顺序:(url, method, data, options)
|
||||
return gatewayHttpClient.request(fullUrl, method, data, options)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 请求
|
||||
*/
|
||||
function get(url, params = {}, options = {}) {
|
||||
return request(url, 'GET', params, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 请求
|
||||
*/
|
||||
function post(url, data = {}, options = {}) {
|
||||
return request(url, 'POST', data, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT 请求
|
||||
*/
|
||||
function put(url, data = {}, options = {}) {
|
||||
return request(url, 'PUT', data, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE 请求
|
||||
*/
|
||||
function del(url, data = {}, options = {}) {
|
||||
return request(url, 'DELETE', data, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件(暂时保留原有逻辑)
|
||||
*/
|
||||
function upload(filePath, formData = {}) {
|
||||
return gatewayHttpClient.uploadFile(filePath,formData)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
request,
|
||||
get,
|
||||
post,
|
||||
put,
|
||||
del,
|
||||
upload,
|
||||
|
||||
// 导出原始客户端供特殊场景使用
|
||||
gatewayHttpClient,
|
||||
devHttpClient
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* Utils 统一导出
|
||||
* 方便各个页面统一引入
|
||||
*/
|
||||
|
||||
const config = require('./config.js')
|
||||
const request = require('./request.js')
|
||||
const api = require('./api.js')
|
||||
const auth = require('./auth.js')
|
||||
const httpClient = require('./httpClient.js')
|
||||
|
||||
module.exports = {
|
||||
// 配置
|
||||
...config,
|
||||
|
||||
// 请求方法
|
||||
...request,
|
||||
|
||||
// API接口
|
||||
...api,
|
||||
|
||||
// 认证
|
||||
...auth,
|
||||
|
||||
// 统一 HTTP 客户端
|
||||
httpClient
|
||||
}
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
/**
|
||||
* 统一请求模块
|
||||
* 封装所有 API 请求,支持拦截器、错误处理等
|
||||
*/
|
||||
|
||||
const { API_BASE, TIMEOUT, ENABLE_LOG, IS_DEV } = require('./config.js')
|
||||
const httpClient = require('./httpClient.js')
|
||||
|
||||
/**
|
||||
* 统一请求方法
|
||||
* @param {Object} options 请求配置
|
||||
* @param {String} options.url 请求路径(相对路径,会自动拼接 API_BASE)
|
||||
* @param {String} options.method 请求方法 GET/POST/PUT/DELETE
|
||||
* @param {Object} options.data 请求数据
|
||||
* @param {Object} options.header 自定义请求头
|
||||
* @param {Boolean} options.showLoading 是否显示加载提示
|
||||
* @param {String} options.loadingText 加载提示文字
|
||||
* @param {Boolean} options.showError 是否显示错误提示
|
||||
* @returns {Promise}
|
||||
*/
|
||||
function request(options = {}) {
|
||||
const {
|
||||
url,
|
||||
method = 'GET',
|
||||
data = {},
|
||||
header = {},
|
||||
showLoading = false,
|
||||
loadingText = '加载中...',
|
||||
showError = true
|
||||
} = options
|
||||
|
||||
// 显示加载提示
|
||||
if (showLoading) {
|
||||
wx.showLoading({
|
||||
title: loadingText,
|
||||
mask: true
|
||||
})
|
||||
}
|
||||
|
||||
// 拼接完整URL
|
||||
const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`
|
||||
|
||||
// 构建请求头
|
||||
const requestHeader = {
|
||||
'Content-Type': 'application/json',
|
||||
...header
|
||||
}
|
||||
|
||||
// 添加 token(如果存在)
|
||||
const token = wx.getStorageSync('token')
|
||||
if (token) {
|
||||
requestHeader['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
// 日志输出
|
||||
if (ENABLE_LOG) {
|
||||
console.log('[API Request]', {
|
||||
url: fullUrl,
|
||||
method,
|
||||
data,
|
||||
header: requestHeader
|
||||
})
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.request({
|
||||
url: fullUrl,
|
||||
method,
|
||||
data,
|
||||
header: requestHeader,
|
||||
timeout: TIMEOUT,
|
||||
success: (res) => {
|
||||
// 隐藏加载提示
|
||||
if (showLoading) {
|
||||
wx.hideLoading()
|
||||
}
|
||||
|
||||
// 日志输出
|
||||
if (ENABLE_LOG) {
|
||||
console.log('[API Response]', res.data)
|
||||
}
|
||||
|
||||
// 统一处理响应
|
||||
const { statusCode, data: resData } = res
|
||||
|
||||
// HTTP 状态码检查
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
// 业务状态码检查
|
||||
if (resData.code === 200 || resData.success === true) {
|
||||
resolve(resData)
|
||||
} else {
|
||||
// 业务错误
|
||||
const errorMsg = resData.msg || resData.message || '请求失败'
|
||||
if (showError) {
|
||||
wx.showToast({
|
||||
title: errorMsg,
|
||||
icon: 'none',
|
||||
duration: 2000
|
||||
})
|
||||
}
|
||||
reject({
|
||||
code: resData.code,
|
||||
msg: errorMsg,
|
||||
data: resData
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// HTTP 错误
|
||||
const errorMsg = `服务器错误 (${statusCode})`
|
||||
if (showError) {
|
||||
wx.showToast({
|
||||
title: errorMsg,
|
||||
icon: 'none',
|
||||
duration: 2000
|
||||
})
|
||||
}
|
||||
reject({
|
||||
code: statusCode,
|
||||
msg: errorMsg,
|
||||
data: res
|
||||
})
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
// 隐藏加载提示
|
||||
if (showLoading) {
|
||||
wx.hideLoading()
|
||||
}
|
||||
|
||||
// 日志输出
|
||||
if (ENABLE_LOG) {
|
||||
console.error('[API Error]', err)
|
||||
}
|
||||
|
||||
// 网络错误
|
||||
const errorMsg = err.errMsg || '网络请求失败'
|
||||
if (showError) {
|
||||
wx.showToast({
|
||||
title: errorMsg,
|
||||
icon: 'none',
|
||||
duration: 2000
|
||||
})
|
||||
}
|
||||
|
||||
reject({
|
||||
code: -1,
|
||||
msg: errorMsg,
|
||||
data: err
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 请求
|
||||
*/
|
||||
function get(url, data = {}, options = {}) {
|
||||
// 直接使用 httpClient,它会自动处理环境切换
|
||||
return httpClient.get(url, data, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 请求
|
||||
*/
|
||||
function post(url, data = {}, options = {}) {
|
||||
// 直接使用 httpClient,它会自动处理环境切换
|
||||
return httpClient.post(url, data, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT 请求
|
||||
*/
|
||||
function put(url, data = {}, options = {}) {
|
||||
// 直接使用 httpClient,它会自动处理环境切换
|
||||
return httpClient.put(url, data, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE 请求
|
||||
*/
|
||||
function del(url, data = {}, options = {}) {
|
||||
// 直接使用 httpClient,它会自动处理环境切换
|
||||
return httpClient.del(url, data, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
* @param {String} url 上传地址
|
||||
* @param {String} filePath 文件路径
|
||||
* @param {Object} formData 额外的表单数据
|
||||
* @param {Object} options 其他配置
|
||||
*/
|
||||
function uploadFile(filePath, formData = {}) {
|
||||
const {
|
||||
name = 'file',
|
||||
showLoading = true,
|
||||
loadingText = '上传中...',
|
||||
showError = true
|
||||
} = options
|
||||
|
||||
if (showLoading) {
|
||||
wx.showLoading({
|
||||
title: loadingText,
|
||||
mask: true
|
||||
})
|
||||
}
|
||||
|
||||
const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`
|
||||
|
||||
// 构建请求头
|
||||
const header = {}
|
||||
const token = wx.getStorageSync('token')
|
||||
if (token) {
|
||||
header['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.uploadFile({
|
||||
url: fullUrl,
|
||||
filePath,
|
||||
name,
|
||||
formData,
|
||||
header,
|
||||
timeout: TIMEOUT,
|
||||
success: (res) => {
|
||||
if (showLoading) {
|
||||
wx.hideLoading()
|
||||
}
|
||||
|
||||
const data = JSON.parse(res.data)
|
||||
if (data.code === 200 || data.success === true) {
|
||||
resolve(data)
|
||||
} else {
|
||||
const errorMsg = data.msg || data.message || '上传失败'
|
||||
if (showError) {
|
||||
wx.showToast({
|
||||
title: errorMsg,
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
reject({
|
||||
code: data.code,
|
||||
msg: errorMsg,
|
||||
data
|
||||
})
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
if (showLoading) {
|
||||
wx.hideLoading()
|
||||
}
|
||||
|
||||
const errorMsg = err.errMsg || '上传失败'
|
||||
if (showError) {
|
||||
wx.showToast({
|
||||
title: errorMsg,
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
reject({
|
||||
code: -1,
|
||||
msg: errorMsg,
|
||||
data: err
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
request,
|
||||
get,
|
||||
post,
|
||||
put,
|
||||
del,
|
||||
uploadFile
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* 本地存储工具
|
||||
* storage key 设计:
|
||||
* draw_learning_progress - 学习进度
|
||||
* draw_recent_history - 最近学习记录
|
||||
* draw_favorite_courses - 收藏课程
|
||||
* draw_works_records - 作品上传记录
|
||||
* draw_user_settings - 用户设置
|
||||
*/
|
||||
|
||||
const KEYS = {
|
||||
PROGRESS: 'draw_learning_progress',
|
||||
HISTORY: 'draw_recent_history',
|
||||
FAVORITE: 'draw_favorite_courses',
|
||||
WORKS: 'draw_works_records',
|
||||
SETTINGS: 'draw_user_settings',
|
||||
}
|
||||
|
||||
// --------- 学习进度 ---------
|
||||
// 格式:{ [courseId]: { currentStep: 0, completed: false, updatedAt: timestamp } }
|
||||
function getProgress() {
|
||||
return wx.getStorageSync(KEYS.PROGRESS) || {}
|
||||
}
|
||||
|
||||
function saveProgress(courseId, stepIndex, completed = false) {
|
||||
const progress = getProgress()
|
||||
progress[courseId] = {
|
||||
currentStep: stepIndex,
|
||||
completed,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
wx.setStorageSync(KEYS.PROGRESS, progress)
|
||||
}
|
||||
|
||||
function getCourseProgress(courseId) {
|
||||
const progress = getProgress()
|
||||
return progress[courseId] || { currentStep: 0, completed: false }
|
||||
}
|
||||
|
||||
function getCompletedCount() {
|
||||
const progress = getProgress()
|
||||
return Object.values(progress).filter(p => p.completed).length
|
||||
}
|
||||
|
||||
// --------- 最近学习记录 ---------
|
||||
// 格式:[{ courseId, courseTitle, coverEmoji, coverColor, stepIndex, timestamp }]
|
||||
function getRecentHistory() {
|
||||
return wx.getStorageSync(KEYS.HISTORY) || []
|
||||
}
|
||||
|
||||
function addRecentHistory(courseInfo) {
|
||||
let history = getRecentHistory()
|
||||
// 去重
|
||||
history = history.filter(h => h.courseId !== courseInfo.courseId)
|
||||
history.unshift({ ...courseInfo, timestamp: Date.now() })
|
||||
// 最多保留10条
|
||||
history = history.slice(0, 10)
|
||||
wx.setStorageSync(KEYS.HISTORY, history)
|
||||
}
|
||||
|
||||
// --------- 收藏课程 ---------
|
||||
// 格式:[courseId]
|
||||
function getFavorites() {
|
||||
return wx.getStorageSync(KEYS.FAVORITE) || []
|
||||
}
|
||||
|
||||
function toggleFavorite(courseId) {
|
||||
let favorites = getFavorites()
|
||||
const idx = favorites.indexOf(courseId)
|
||||
if (idx >= 0) {
|
||||
favorites.splice(idx, 1)
|
||||
} else {
|
||||
favorites.unshift(courseId)
|
||||
}
|
||||
wx.setStorageSync(KEYS.FAVORITE, favorites)
|
||||
return idx < 0 // true=已收藏
|
||||
}
|
||||
|
||||
function isFavorite(courseId) {
|
||||
return getFavorites().includes(courseId)
|
||||
}
|
||||
|
||||
// --------- 作品记录 ---------
|
||||
// 格式:[{ id, courseId, courseTitle, imagePath, savedAt }]
|
||||
function getWorksRecords() {
|
||||
return wx.getStorageSync(KEYS.WORKS) || []
|
||||
}
|
||||
|
||||
function addWorkRecord(record) {
|
||||
let records = getWorksRecords()
|
||||
records.unshift({ ...record, savedAt: Date.now(), id: Date.now().toString() })
|
||||
wx.setStorageSync(KEYS.WORKS, records)
|
||||
}
|
||||
|
||||
function getCompletedPracticeCount() {
|
||||
return getWorksRecords().length
|
||||
}
|
||||
|
||||
// --------- 用户设置 ---------
|
||||
function getSettings() {
|
||||
return wx.getStorageSync(KEYS.SETTINGS) || {
|
||||
reminderEnabled: false,
|
||||
reminderTime: '08:00',
|
||||
nickname: '学画的你',
|
||||
avatar: '',
|
||||
joinDays: 0,
|
||||
joinDate: null
|
||||
}
|
||||
}
|
||||
|
||||
function saveSettings(settings) {
|
||||
const current = getSettings()
|
||||
const merged = { ...current, ...settings }
|
||||
// 计算学习天数
|
||||
if (!merged.joinDate) {
|
||||
merged.joinDate = Date.now()
|
||||
merged.joinDays = 1
|
||||
} else {
|
||||
merged.joinDays = Math.max(1, Math.ceil((Date.now() - merged.joinDate) / 86400000))
|
||||
}
|
||||
wx.setStorageSync(KEYS.SETTINGS, merged)
|
||||
return merged
|
||||
}
|
||||
|
||||
function initSettings() {
|
||||
const settings = getSettings()
|
||||
if (!settings.joinDate) {
|
||||
saveSettings({})
|
||||
}
|
||||
return getSettings()
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
saveProgress,
|
||||
getCourseProgress,
|
||||
getCompletedCount,
|
||||
getRecentHistory,
|
||||
addRecentHistory,
|
||||
getFavorites,
|
||||
toggleFavorite,
|
||||
isFavorite,
|
||||
getWorksRecords,
|
||||
addWorkRecord,
|
||||
getCompletedPracticeCount,
|
||||
getSettings,
|
||||
saveSettings,
|
||||
initSettings
|
||||
}
|
||||