first commit

This commit is contained in:
honghefly 2026-05-08 17:00:10 +08:00
commit 6c25d242cf
76 changed files with 4618 additions and 0 deletions

100
app.js Normal file
View File

@ -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 });
}
}))

47
app.json Normal file
View File

@ -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"
}

96
app.wxss Normal file
View File

@ -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);
}

BIN
images/tabbar/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 880 B

3
images/tabbar/home.svg Normal file
View File

@ -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

BIN
images/tabbar/home@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1009 B

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
images/tabbar/practice.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 B

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 761 B

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
images/tabbar/profile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

295
miniprogram_npm/@jdmini/api/index.d.ts vendored Normal file
View File

@ -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;
}
}

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -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: {
}
})

View File

@ -0,0 +1,3 @@
{
"component": true
}

View File

@ -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>

View File

@ -0,0 +1,7 @@
.jdwx-ad-component {
padding: 10rpx;
}
.jdwx-ad-item {
bottom: 10rpx;
}

View File

@ -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
});
},
}
})

View File

@ -0,0 +1,3 @@
{
"component": true
}

View File

@ -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>

View File

@ -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);
}

26
package-lock.json generated Normal file
View File

@ -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"
}
}
}
}

6
package.json Normal file
View File

@ -0,0 +1,6 @@
{
"dependencies": {
"@jdmini/api": "^1.0.10",
"@jdmini/components": "^1.0.6"
}
}

View File

@ -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' }
}
}))

View File

@ -0,0 +1,4 @@
{
"navigationBarTitleText": "课程分类",
"backgroundColor": "#f7f8fc"
}

View File

@ -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>

View File

@ -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;
}

View File

@ -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 : ''}`
}
}
}))

View File

@ -0,0 +1,4 @@
{
"navigationBarTitleText": "课程详情",
"backgroundColor": "#f7f8fc"
}

View File

@ -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>

View File

@ -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%;
}

68
pages/home/home.js Normal file
View File

@ -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'
}
}
}))

6
pages/home/home.json Normal file
View File

@ -0,0 +1,6 @@
{
"navigationBarTitleText": "画画怎么画",
"navigationBarBackgroundColor": "#6C8CFF",
"navigationBarTextStyle": "white",
"backgroundColor": "#f7f8fc"
}

126
pages/home/home.wxml Normal file
View File

@ -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>

241
pages/home/home.wxss Normal file
View File

@ -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;
}

View File

@ -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' }
}
}))

View File

@ -0,0 +1,4 @@
{
"navigationBarTitleText": "每日练习",
"backgroundColor": "#f7f8fc"
}

View File

@ -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>

View File

@ -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;
}

86
pages/profile/profile.js Normal file
View File

@ -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' }
}
}))

View File

@ -0,0 +1,4 @@
{
"navigationBarTitleText": "我的",
"backgroundColor": "#f7f8fc"
}

111
pages/profile/profile.wxml Normal file
View File

@ -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>

214
pages/profile/profile.wxss Normal file
View File

@ -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;
}

View File

@ -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 : ''}`
}
}
}))

View File

@ -0,0 +1,4 @@
{
"navigationBarTitleText": "学习",
"backgroundColor": "#f7f8fc"
}

View File

@ -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>

View File

@ -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;
}

View File

@ -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'
}
}
}))

View File

@ -0,0 +1,4 @@
{
"navigationBarTitleText": "作品记录",
"backgroundColor": "#f7f8fc"
}

View File

@ -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>

View File

@ -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;
}

41
project.config.json Normal file
View File

@ -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": {}
}

View File

@ -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": {}
}

7
sitemap.json Normal file
View File

@ -0,0 +1,7 @@
{
"desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html",
"rules": [{
"action": "allow",
"page": "*"
}]
}

10
utils/api.js Normal file
View File

@ -0,0 +1,10 @@
/**
* API 接口定义
* 画画怎么画 纯本地存储项目无后端 API 调用
* 所有数据操作通过 utils/storage.js 进行
*/
// 本项目为本地存储型工具,所有接口通过 storage.js 实现
// 如后续需要接入后端,在此统一定义接口
module.exports = {}

269
utils/auth.js Normal file
View File

@ -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
}

36
utils/config.js Normal file
View File

@ -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
}
}

644
utils/data.js Normal file
View File

@ -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)
}
}

88
utils/httpClient.js Normal file
View File

@ -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
}

27
utils/index.js Normal file
View File

@ -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
}

278
utils/request.js Normal file
View File

@ -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
}

148
utils/storage.js Normal file
View File

@ -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
}