commit 6c25d242cf994ce4a2ef2077569912b5f88b72a0 Author: honghefly Date: Fri May 8 17:00:10 2026 +0800 first commit diff --git a/app.js b/app.js new file mode 100644 index 0000000..8377930 --- /dev/null +++ b/app.js @@ -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 }); + } +})) diff --git a/app.json b/app.json new file mode 100644 index 0000000..0c9da20 --- /dev/null +++ b/app.json @@ -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" +} diff --git a/app.wxss b/app.wxss new file mode 100644 index 0000000..7f208bd --- /dev/null +++ b/app.wxss @@ -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); +} diff --git a/images/tabbar/home.png b/images/tabbar/home.png new file mode 100644 index 0000000..ef044db Binary files /dev/null and b/images/tabbar/home.png differ diff --git a/images/tabbar/home.svg b/images/tabbar/home.svg new file mode 100644 index 0000000..bda778e --- /dev/null +++ b/images/tabbar/home.svg @@ -0,0 +1,3 @@ + + + diff --git a/images/tabbar/home@2x.png b/images/tabbar/home@2x.png new file mode 100644 index 0000000..fb25b55 Binary files /dev/null and b/images/tabbar/home@2x.png differ diff --git a/images/tabbar/home_active.png b/images/tabbar/home_active.png new file mode 100644 index 0000000..c4964ab Binary files /dev/null and b/images/tabbar/home_active.png differ diff --git a/images/tabbar/home_active.svg b/images/tabbar/home_active.svg new file mode 100644 index 0000000..3f58585 --- /dev/null +++ b/images/tabbar/home_active.svg @@ -0,0 +1,3 @@ + + + diff --git a/images/tabbar/home_active@2x.png b/images/tabbar/home_active@2x.png new file mode 100644 index 0000000..7cf48b9 Binary files /dev/null and b/images/tabbar/home_active@2x.png differ diff --git a/images/tabbar/practice.png b/images/tabbar/practice.png new file mode 100644 index 0000000..d1e382f Binary files /dev/null and b/images/tabbar/practice.png differ diff --git a/images/tabbar/practice.svg b/images/tabbar/practice.svg new file mode 100644 index 0000000..9796126 --- /dev/null +++ b/images/tabbar/practice.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/images/tabbar/practice@2x.png b/images/tabbar/practice@2x.png new file mode 100644 index 0000000..cffebd0 Binary files /dev/null and b/images/tabbar/practice@2x.png differ diff --git a/images/tabbar/practice_active.png b/images/tabbar/practice_active.png new file mode 100644 index 0000000..fa3b006 Binary files /dev/null and b/images/tabbar/practice_active.png differ diff --git a/images/tabbar/practice_active.svg b/images/tabbar/practice_active.svg new file mode 100644 index 0000000..d2c5e0c --- /dev/null +++ b/images/tabbar/practice_active.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/images/tabbar/practice_active@2x.png b/images/tabbar/practice_active@2x.png new file mode 100644 index 0000000..1d540d2 Binary files /dev/null and b/images/tabbar/practice_active@2x.png differ diff --git a/images/tabbar/profile.png b/images/tabbar/profile.png new file mode 100644 index 0000000..732123f Binary files /dev/null and b/images/tabbar/profile.png differ diff --git a/images/tabbar/profile.svg b/images/tabbar/profile.svg new file mode 100644 index 0000000..097ed45 --- /dev/null +++ b/images/tabbar/profile.svg @@ -0,0 +1,4 @@ + + + + diff --git a/images/tabbar/profile@2x.png b/images/tabbar/profile@2x.png new file mode 100644 index 0000000..1a1e79e Binary files /dev/null and b/images/tabbar/profile@2x.png differ diff --git a/images/tabbar/profile_active.png b/images/tabbar/profile_active.png new file mode 100644 index 0000000..d71fbed Binary files /dev/null and b/images/tabbar/profile_active.png differ diff --git a/images/tabbar/profile_active.svg b/images/tabbar/profile_active.svg new file mode 100644 index 0000000..8aa213c --- /dev/null +++ b/images/tabbar/profile_active.svg @@ -0,0 +1,4 @@ + + + + diff --git a/images/tabbar/profile_active@2x.png b/images/tabbar/profile_active@2x.png new file mode 100644 index 0000000..d52211c Binary files /dev/null and b/images/tabbar/profile_active@2x.png differ diff --git a/miniprogram_npm/@jdmini/api/index.d.ts b/miniprogram_npm/@jdmini/api/index.d.ts new file mode 100644 index 0000000..e06e58d --- /dev/null +++ b/miniprogram_npm/@jdmini/api/index.d.ts @@ -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; + export function showPage(options: PageOptions | undefined, pageId: string): Promise; + export const checkTokenValid: () => boolean; + /** + * 确保登录完成 + * @param {Function} callback - 回调函数 + * @returns {void} + */ + export function onLoginReady(callback: (...args: any[]) => void): void; + /** + * 等待登录完成 + * @returns {Promise} + */ + export function waitLogin(): Promise; + export function login(): Promise; + export function fetchEchoData(): Promise; + export function trackVisit(): Promise; +} + +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} 返回一个Promise对象 + */ + request(path: string, method?: WechatMiniprogram.RequestOption['method'], data?: Record, options?: RequestOptions): Promise>; + /** + * 上传文件 + * @param {string} filePath 文件路径 + * @param {Object} data 数据, 默认{} + * @param {'avatar' | 'file'} type 类型, 默认'file' + * @returns {Promise} 返回一个Promise对象 + */ + uploadFile(filePath: string, data?: Record, type?: 'avatar' | 'file'): Promise>; + /** + * 上传文件 + * @param {string} filePath 文件路径 + * @param {Object} data 数据, 默认{} + * @param {'avatar' | 'file'} type 类型, 默认'file' + * @returns {Promise} 返回一个Promise对象 + */ + upload(path: string, filePath: string, data?: Record): Promise>; + /** + * 删除文件 + * @param {number} fileId 文件id + * @returns {Promise} 返回一个Promise对象 + */ + deleteFile(fileId: number): Promise>; + /** + * 上传头像 + * @param {string} filePath 文件路径 + * @returns {Promise} 返回一个Promise对象 + */ + uploadAvatar(filePath: string): Promise>; + } + 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; + [key: string]: any; + } + interface PageConfig { + onShow?: (...args: any[]) => void | Promise; + [key: string]: any; + } + interface ComponentConfig { + methods?: { + onLoad?: (...args: any[]) => void | Promise; + onShow?: (...args: any[]) => void | Promise; + [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>; + class AdManager { + /** + * 广告数据 + */ + ads: Ads; + /** + * 友情链接数据 + */ + link: LinkData[]; + /** + * 友情链接顶部广告数据 + */ + top: TopData | null; + constructor(); + /** + * 确保广告数据就绪 + * @param {Function} callback - 回调函数 + * @returns {void} + */ + onDataReady: (callback: (...args: any[]) => void) => void; + /** + * 等待广告数据加载完成 + * @returns {Promise} + */ + waitAdData: () => Promise; + /** + * 初始化广告数据 + * @returns {void} + */ + init: () => void; + /** + * 创建并展示插屏广告 + * @returns {Promise} + */ + createAndShowInterstitialAd: () => Promise; + /** + * 创建并展示激励视频广告 + * @param {any} context - 页面上下文 + * @param {string} [pageId] - 页面ID + * @returns {Promise} 是否完成播放 + */ + createAndShowRewardedVideoAd: (context: any, pageId?: string) => Promise; + } + 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; + [key: string]: any; + } + export interface LoginData { + appId: number; + code: string; + brand: string; + model: string; + platform: string; + } + export interface ApiResponse { + 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; + } +} + diff --git a/miniprogram_npm/@jdmini/api/index.js b/miniprogram_npm/@jdmini/api/index.js new file mode 100644 index 0000000..fc11843 --- /dev/null +++ b/miniprogram_npm/@jdmini/api/index.js @@ -0,0 +1,4 @@ +/*! + * @jdmini/api v1.0.10 + * + */(()=>{"use strict";var t={616:function(t,e,n){var r=this&&this.__awaiter||function(t,e,n,r){return new(n||(n=Promise))((function(o,a){function i(t){try{u(r.next(t))}catch(t){a(t)}}function s(t){try{u(r.throw(t))}catch(t){a(t)}}function u(t){var e;t.done?o(t.value):(e=t.value,e instanceof n?e:new n((function(t){t(e)}))).then(i,s)}u((r=r.apply(t,e||[])).next())}))},o=this&&this.__generator||function(t,e){var n,r,o,a={label:0,sent:function(){if(1&o[0])throw o[1];return o[1]},trys:[],ops:[]},i=Object.create(("function"==typeof Iterator?Iterator:Object).prototype);return i.next=s(0),i.throw=s(1),i.return=s(2),"function"==typeof Symbol&&(i[Symbol.iterator]=function(){return this}),i;function s(s){return function(u){return function(s){if(n)throw new TypeError("Generator is already executing.");for(;i&&(i=0,s[0]&&(a=0)),a;)try{if(n=1,r&&(o=2&s[0]?r.return:s[0]?r.throw||((o=r.return)&&o.call(r),0):r.next)&&!(o=o.call(r,s[1])).done)return o;switch(r=0,o&&(s=[2&s[0],o.value]),s[0]){case 0:case 1:o=s;break;case 4:return a.label++,{value:s[1],done:!1};case 5:a.label++,r=s[1],s=[0];continue;case 7:s=a.ops.pop(),a.trys.pop();continue;default:if(!(o=a.trys,(o=o.length>0&&o[o.length-1])||6!==s[0]&&2!==s[0])){a=0;continue}if(3===s[0]&&(!o||s[1]>o[0]&&s[1]0?e.ads[0].adUnitId:"",t}),{}));var n=wx.getStorageSync(i.default.STORAGE_KEYS.LINK_DATA);n&&(t.link=n);var r=wx.getStorageSync(i.default.STORAGE_KEYS.TOP_DATA);r&&r.appDsc.length>40&&(r.appDsc=r.appDsc.substring(0,40)+"...",t.top=r),t.adDataReady=!0,s.default.emit(i.default.EVENT_KEYS.AD_DATA_READY)},this.createAndShowInterstitialAd=function(){return r(t,void 0,void 0,(function(){var t;return o(this,(function(e){switch(e.label){case 0:return this.interstitialAd&&this.interstitialAd.destroy(),[4,this.waitAdData()];case 1:e.sent(),e.label=2;case 2:return e.trys.push([2,5,,6]),[4,this.createInterstitialAd()];case 3:return e.sent(),[4,this.showInterstitialAd()];case 4:return e.sent(),[3,6];case 5:return t=e.sent(),console.error("创建插屏广告失败:",t),[3,6];case 6:return[2]}}))}))},this.createInterstitialAd=function(){return r(t,void 0,void 0,(function(){var t=this;return o(this,(function(e){return[2,new Promise((function(e,n){t.ads.interstitial?(t.interstitialAd=wx.createInterstitialAd({adUnitId:t.ads.interstitial}),t.interstitialAd.onLoad((function(){console.log("插屏广告加载成功"),e()})),t.interstitialAd.onError((function(t){console.error(t),n(new Error("插屏广告加载失败"))})),t.interstitialAd.onClose((function(){console.log("插屏广告关闭")}))):n(new Error("插屏广告未配置"))}))]}))}))},this.showInterstitialAd=function(){return r(t,void 0,void 0,(function(){var t,e;return o(this,(function(n){switch(n.label){case 0:return n.trys.push([0,2,,3]),console.log("开始展示插屏广告"),[4,null===(e=this.interstitialAd)||void 0===e?void 0:e.show()];case 1:return[2,n.sent()];case 2:return t=n.sent(),console.error("插屏广告展示失败:",t),[3,3];case 3:return[2]}}))}))},this.createAndShowRewardedVideoAd=function(e,n){return r(t,void 0,void 0,(function(){var t,r,a;return o(this,(function(o){switch(o.label){case 0:return[4,this.waitAdData()];case 1:o.sent(),t=n||(null===(a=null==e?void 0:e.data)||void 0===a?void 0:a[i.default.DATA.PAGE_ID]),o.label=2;case 2:if(o.trys.push([2,6,,7]),!t)throw new Error("未指定pageId或者context");return this.rewardedVideoAds[t]?[3,4]:[4,this.createRewardedVideoAd(t)];case 3:o.sent(),o.label=4;case 4:return[4,this.showRewardedVideoAd(t)];case 5:return o.sent(),[3,7];case 6:return r=o.sent(),console.error("创建激励视频广告失败:",r),[3,7];case 7:return[2,new Promise((function(e){s.default.on(i.default.EVENT_KEYS.REWARDED_VIDEO_AD_CLOSE,(function(n,r){n===t&&e(r)}))}))]}}))}))},this.createRewardedVideoAd=function(e){return r(t,void 0,void 0,(function(){var t=this;return o(this,(function(n){return[2,new Promise((function(n,r){t.ads.rewarded?(t.rewardedVideoAds[e]=wx.createRewardedVideoAd({adUnitId:t.ads.rewarded}),t.rewardedVideoAds[e].onLoad((function(){console.log("激励视频广告加载成功"),n()})),t.rewardedVideoAds[e].onError((function(t){console.error(t),r(new Error("激励视频广告加载失败"))})),t.rewardedVideoAds[e].onClose((function(t){s.default.emit(i.default.EVENT_KEYS.REWARDED_VIDEO_AD_CLOSE,e,t.isEnded),console.log("激励视频广告关闭")}))):r(new Error("激励视频广告未配置"))}))]}))}))},this.showRewardedVideoAd=function(e){return r(t,void 0,void 0,(function(){var t,n;return o(this,(function(r){switch(r.label){case 0:return r.trys.push([0,2,,3]),console.log("开始展示激励视频广告"),[4,null===(n=this.rewardedVideoAds[e])||void 0===n?void 0:n.show()];case 1:return[2,r.sent()];case 2:return t=r.sent(),console.error("激励视频广告展示失败:",t),[3,3];case 3:return[2]}}))}))}};e.default=new u},859:function(t,e,n){var r=this&&this.__awaiter||function(t,e,n,r){return new(n||(n=Promise))((function(o,a){function i(t){try{u(r.next(t))}catch(t){a(t)}}function s(t){try{u(r.throw(t))}catch(t){a(t)}}function u(t){var e;t.done?o(t.value):(e=t.value,e instanceof n?e:new n((function(t){t(e)}))).then(i,s)}u((r=r.apply(t,e||[])).next())}))},o=this&&this.__generator||function(t,e){var n,r,o,a={label:0,sent:function(){if(1&o[0])throw o[1];return o[1]},trys:[],ops:[]},i=Object.create(("function"==typeof Iterator?Iterator:Object).prototype);return i.next=s(0),i.throw=s(1),i.return=s(2),"function"==typeof Symbol&&(i[Symbol.iterator]=function(){return this}),i;function s(s){return function(u){return function(s){if(n)throw new TypeError("Generator is already executing.");for(;i&&(i=0,s[0]&&(a=0)),a;)try{if(n=1,r&&(o=2&s[0]?r.return:s[0]?r.throw||((o=r.return)&&o.call(r),0):r.next)&&!(o=o.call(r,s[1])).done)return o;switch(r=0,o&&(s=[2&s[0],o.value]),s[0]){case 0:case 1:o=s;break;case 4:return a.label++,{value:s[1],done:!1};case 5:a.label++,r=s[1],s=[0];continue;case 7:s=a.ops.pop(),a.trys.pop();continue;default:if(!(o=a.trys,(o=o.length>0&&o[o.length-1])||6!==s[0]&&2!==s[0])){a=0;continue}if(3===s[0]&&(!o||s[1]>o[0]&&s[1]s.default.APP.LOGIN_MAX_RETRY)throw wx.showToast({title:"网络异常,无法初始化",icon:"none",duration:2e3}),new Error("网络异常,无法初始化");o.label=1;case 1:return o.trys.push([1,7,,9]),t=wx.getDeviceInfo(),[4,wx.login()];case 2:return e=o.sent().code,n={appId:s.default.APP.APP_ID,code:e,brand:t.brand,model:t.model,platform:t.platform},[4,i.gatewayHttpClient.request("/wx/v1/api/login","POST",n)];case 3:return 200===(r=o.sent()).code&&r.data?(wx.setStorageSync(s.default.STORAGE_KEYS.TOKEN,r.data.token),wx.setStorageSync(s.default.STORAGE_KEYS.USER_INFO,r.data.user),l=0,u.default.emit(s.default.EVENT_KEYS.LOGIN_SUCCESS),[3,6]):[3,4];case 4:return l++,[4,h()];case 5:o.sent(),o.label=6;case 6:return[3,9];case 7:return o.sent(),l++,[4,h()];case 8:return o.sent(),[3,9];case 9:return[2]}}))}))}function p(){return r(this,void 0,void 0,(function(){var t;return o(this,(function(n){switch(n.label){case 0:return wx.removeStorageSync(s.default.STORAGE_KEYS.SPA_DATA),wx.removeStorageSync(s.default.STORAGE_KEYS.LINK_DATA),wx.removeStorageSync(s.default.STORAGE_KEYS.TOP_DATA),(0,e.checkTokenValid)()?[4,i.gatewayHttpClient.request("/wx/v1/api/echo","GET")]:[2];case 1:return 200===(t=n.sent()).code&&t.data?(t.data.spads&&wx.setStorageSync(s.default.STORAGE_KEYS.SPA_DATA,t.data.spads),t.data.links&&wx.setStorageSync(s.default.STORAGE_KEYS.LINK_DATA,t.data.links),t.data.top&&wx.setStorageSync(s.default.STORAGE_KEYS.TOP_DATA,t.data.top),[3,5]):[3,2];case 2:return 401!==t.code?[3,5]:[4,h()];case 3:return n.sent(),[4,p()];case 4:n.sent(),n.label=5;case 5:return[2]}}))}))}function w(){return r(this,void 0,void 0,(function(){var t;return o(this,(function(e){switch(e.label){case 0:return e.trys.push([0,5,,6]),[4,i.gatewayHttpClient.request("/wx/v1/api/visit","POST")];case 1:return 401!==e.sent().code?[3,4]:[4,h()];case 2:return e.sent(),[4,w()];case 3:e.sent(),e.label=4;case 4:return[3,6];case 5:return t=e.sent(),console.error("访问统计失败:",t),[3,6];case 6:return[2]}}))}))}e.checkTokenValid=function(){var t=wx.getStorageSync(s.default.STORAGE_KEYS.TOKEN);return!(!t||t.length<32)}},28:(t,e)=>{Object.defineProperty(e,"__esModule",{value:!0});var n={API:{GATEWAY_URL:"https://ca.miniappapi.com",BASE_URL:"https://app.jd027.com/v1/api",API_URL:"https://cp.miniappapi.com"},APP:{APP_ID:wx.getExtConfigSync().appId||313,LOGIN_MAX_RETRY:2},HTTP:{TIMEOUT:5e3},DATA:{PAGE_ID:"jdwx-page-id"},STORAGE_KEYS:{TOKEN:"jdwx-token",USER_INFO:"jdwx-userinfo",SPA_DATA:"jdwx-spadata",LINK_DATA:"jdwx-linkdata",TOP_DATA:"jdwx-topdata"},EVENT_KEYS:{LOGIN_SUCCESS:"jdwx-login-success",AD_DATA_READY:"jdwx-ad-data-ready",REWARDED_VIDEO_AD_CLOSE:"jdwx-rewarded-video-ad-close"}};e.default=n},144:(t,e)=>{Object.defineProperty(e,"__esModule",{value:!0});var n=function(){function t(){this.events={}}return t.prototype.on=function(t,e){if("string"!=typeof t)throw new TypeError("eventName must be a string");if("function"!=typeof e)throw new TypeError("callback must be a function");return this.events[t]||(this.events[t]=[]),this.events[t].push(e),this},t.prototype.emit=function(t){for(var e=[],n=1;n0&&o[o.length-1])||6!==s[0]&&2!==s[0])){a=0;continue}if(3===s[0]&&(!o||s[1]>o[0]&&s[1]=200&&d.statusCode<300)return[2,d.data];if(401===d.statusCode)return[2,{code:401,message:"未授权"}];throw new Error(d.data.message||"请求失败");case 4:throw f=a.sent(),console.error("网络错误:",f),wx.showToast({title:f instanceof Error?f.message:"网络错误",icon:"none",duration:2e3}),f;case 5:return[2]}}))}))},t.prototype.uploadFile=function(t){return o(this,arguments,void 0,(function(t,e,n){var r,o,i,u;return void 0===e&&(e={}),void 0===n&&(n="file"),a(this,(function(a){r=this.baseURL===s.default.API.GATEWAY_URL,o=wx.getStorageSync(s.default.STORAGE_KEYS.TOKEN),i="avatar"===n?"/avatar":"/file/new",u=this.joinURL(this.baseURL,"".concat(r?"/wx/v1/api":"").concat(i));try{return[2,new Promise((function(n,r){wx.uploadFile({url:u,name:"file",filePath:t,formData:e,header:{"Content-Type":"application/x-www-form-urlencoded",Authorization:o},success:function(t){if(t.statusCode>=200&&t.statusCode<300)n(JSON.parse(t.data));else{if(401!==t.statusCode)throw new Error(t.data.message||"上传失败");n(JSON.parse(t.data))}},fail:function(){throw new Error("网络错误")}}).onProgressUpdate((function(t){console.log("上传进度",t.progress),console.log("已经上传的数据长度",t.totalBytesSent),console.log("预期需要上传的数据总长度",t.totalBytesExpectedToSend)}))}))]}catch(t){throw console.error("上传失败:",t),wx.showToast({title:t instanceof Error?t.message:"上传失败",icon:"none",duration:2e3}),t}return[2]}))}))},t.prototype.upload=function(t,e){return o(this,arguments,void 0,(function(t,e,n){var r,o,i;return void 0===n&&(n={}),a(this,(function(a){r=this.baseURL===s.default.API.GATEWAY_URL,o=wx.getStorageSync(s.default.STORAGE_KEYS.TOKEN),i=this.joinURL(this.baseURL,"".concat(r?"/wx/v1/api":"").concat(t));try{return[2,new Promise((function(t,r){wx.uploadFile({url:i,name:"file",filePath:e,formData:n,header:{"Content-Type":"application/x-www-form-urlencoded",Authorization:o},success:function(e){if(e.statusCode>=200&&e.statusCode<300)t(JSON.parse(e.data));else{if(401!==e.statusCode)throw new Error(e.data.message||"上传失败");t(JSON.parse(e.data))}},fail:function(){throw new Error("网络错误")}}).onProgressUpdate((function(t){console.log("上传进度",t.progress),console.log("已经上传的数据长度",t.totalBytesSent),console.log("预期需要上传的数据总长度",t.totalBytesExpectedToSend)}))}))]}catch(t){throw console.error("上传失败:",t),wx.showToast({title:t instanceof Error?t.message:"上传失败",icon:"none",duration:2e3}),t}return[2]}))}))},t.prototype.deleteFile=function(t){return o(this,void 0,void 0,(function(){var e;return a(this,(function(n){return e=this.baseURL===s.default.API.GATEWAY_URL,[2,this.request("".concat(e?"/wx/v1/api":"","/file/del"),"GET",{id:t})]}))}))},t.prototype.uploadAvatar=function(t){return o(this,void 0,void 0,(function(){return a(this,(function(e){return[2,this.uploadFile(t,{},"avatar")]}))}))},t}();e.gatewayHttpClient=new u({baseURL:s.default.API.GATEWAY_URL,timeout:s.default.HTTP.TIMEOUT}),e.baseHttpClient=new u({baseURL:s.default.API.BASE_URL,timeout:s.default.HTTP.TIMEOUT}),e.apiHttpClient=new u({baseURL:s.default.API.API_URL,timeout:s.default.HTTP.TIMEOUT}),e.default=u},156:function(t,e,n){var r=this&&this.__createBinding||(Object.create?function(t,e,n,r){void 0===r&&(r=n);var o=Object.getOwnPropertyDescriptor(e,n);o&&!("get"in o?!e.__esModule:o.writable||o.configurable)||(o={enumerable:!0,get:function(){return e[n]}}),Object.defineProperty(t,r,o)}:function(t,e,n,r){void 0===r&&(r=n),t[r]=e[n]}),o=this&&this.__setModuleDefault||(Object.create?function(t,e){Object.defineProperty(t,"default",{enumerable:!0,value:e})}:function(t,e){t.default=e}),a=this&&this.__importStar||function(t){if(t&&t.__esModule)return t;var e={};if(null!=t)for(var n in t)"default"!==n&&Object.prototype.hasOwnProperty.call(t,n)&&r(e,t,n);return o(e,t),e},i=this&&this.__importDefault||function(t){return t&&t.__esModule?t:{default:t}};Object.defineProperty(e,"__esModule",{value:!0}),e.adManager=e.HttpClient=e.apiHttpClient=e.baseHttpClient=e.gatewayHttpClient=e.hijackAllPage=e.hijackApp=e.injectComponent=e.injectPage=e.injectApp=e.waitLogin=e.onLoginReady=void 0;var s=n(859);Object.defineProperty(e,"onLoginReady",{enumerable:!0,get:function(){return s.onLoginReady}}),Object.defineProperty(e,"waitLogin",{enumerable:!0,get:function(){return s.waitLogin}});var u=a(n(161));e.HttpClient=u.default,Object.defineProperty(e,"gatewayHttpClient",{enumerable:!0,get:function(){return u.gatewayHttpClient}}),Object.defineProperty(e,"baseHttpClient",{enumerable:!0,get:function(){return u.baseHttpClient}}),Object.defineProperty(e,"apiHttpClient",{enumerable:!0,get:function(){return u.apiHttpClient}});var c=n(718);Object.defineProperty(e,"injectApp",{enumerable:!0,get:function(){return c.injectApp}}),Object.defineProperty(e,"injectPage",{enumerable:!0,get:function(){return c.injectPage}}),Object.defineProperty(e,"injectComponent",{enumerable:!0,get:function(){return c.injectComponent}}),Object.defineProperty(e,"hijackApp",{enumerable:!0,get:function(){return c.hijackApp}}),Object.defineProperty(e,"hijackAllPage",{enumerable:!0,get:function(){return c.hijackAllPage}});var l=i(n(616));e.adManager=l.default},718:function(t,e,n){var r=this&&this.__assign||function(){return r=Object.assign||function(t){for(var e,n=1,r=arguments.length;n { + this.setData({ ads: adManager.ads }) + }) + }, + }, + methods: { + } +}) diff --git a/miniprogram_npm/@jdmini/components/jdwx-ad/index.json b/miniprogram_npm/@jdmini/components/jdwx-ad/index.json new file mode 100644 index 0000000..32640e0 --- /dev/null +++ b/miniprogram_npm/@jdmini/components/jdwx-ad/index.json @@ -0,0 +1,3 @@ +{ + "component": true +} \ No newline at end of file diff --git a/miniprogram_npm/@jdmini/components/jdwx-ad/index.wxml b/miniprogram_npm/@jdmini/components/jdwx-ad/index.wxml new file mode 100644 index 0000000..1b24adf --- /dev/null +++ b/miniprogram_npm/@jdmini/components/jdwx-ad/index.wxml @@ -0,0 +1,5 @@ + + + + + diff --git a/miniprogram_npm/@jdmini/components/jdwx-ad/index.wxss b/miniprogram_npm/@jdmini/components/jdwx-ad/index.wxss new file mode 100644 index 0000000..de627db --- /dev/null +++ b/miniprogram_npm/@jdmini/components/jdwx-ad/index.wxss @@ -0,0 +1,7 @@ +.jdwx-ad-component { + padding: 10rpx; +} + +.jdwx-ad-item { + bottom: 10rpx; +} \ No newline at end of file diff --git a/miniprogram_npm/@jdmini/components/jdwx-link/index.js b/miniprogram_npm/@jdmini/components/jdwx-link/index.js new file mode 100644 index 0000000..887d749 --- /dev/null +++ b/miniprogram_npm/@jdmini/components/jdwx-link/index.js @@ -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 + }); + }, + } +}) diff --git a/miniprogram_npm/@jdmini/components/jdwx-link/index.json b/miniprogram_npm/@jdmini/components/jdwx-link/index.json new file mode 100644 index 0000000..fba482a --- /dev/null +++ b/miniprogram_npm/@jdmini/components/jdwx-link/index.json @@ -0,0 +1,3 @@ +{ + "component": true +} \ No newline at end of file diff --git a/miniprogram_npm/@jdmini/components/jdwx-link/index.wxml b/miniprogram_npm/@jdmini/components/jdwx-link/index.wxml new file mode 100644 index 0000000..30ff009 --- /dev/null +++ b/miniprogram_npm/@jdmini/components/jdwx-link/index.wxml @@ -0,0 +1,11 @@ + + + + {{top.linkName}} + {{top.appDsc}} + + + + {{item.linkName}} + + \ No newline at end of file diff --git a/miniprogram_npm/@jdmini/components/jdwx-link/index.wxss b/miniprogram_npm/@jdmini/components/jdwx-link/index.wxss new file mode 100644 index 0000000..111b9f1 --- /dev/null +++ b/miniprogram_npm/@jdmini/components/jdwx-link/index.wxss @@ -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); +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5e075aa --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3a13fe8 --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "dependencies": { + "@jdmini/api": "^1.0.10", + "@jdmini/components": "^1.0.6" + } +} diff --git a/pages/category/category.js b/pages/category/category.js new file mode 100644 index 0000000..603308b --- /dev/null +++ b/pages/category/category.js @@ -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' } + } +})) diff --git a/pages/category/category.json b/pages/category/category.json new file mode 100644 index 0000000..2c1c23a --- /dev/null +++ b/pages/category/category.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "课程分类", + "backgroundColor": "#f7f8fc" +} diff --git a/pages/category/category.wxml b/pages/category/category.wxml new file mode 100644 index 0000000..184514c --- /dev/null +++ b/pages/category/category.wxml @@ -0,0 +1,51 @@ + + + + + + {{item}} + + + + + + + 搜索"{{keyword}}"的结果,共{{courseList.length}}个课程 + + + + + + + + {{item.coverEmoji}} + + + {{item.title}} + + {{item.difficulty}} + {{item.lessons}}节课 + + ⏱ {{item.duration}} + + + + + 🔍 + 暂无该分类的课程 + + + + diff --git a/pages/category/category.wxss b/pages/category/category.wxss new file mode 100644 index 0000000..9beb6d3 --- /dev/null +++ b/pages/category/category.wxss @@ -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; +} diff --git a/pages/course-detail/course-detail.js b/pages/course-detail/course-detail.js new file mode 100644 index 0000000..0f33335 --- /dev/null +++ b/pages/course-detail/course-detail.js @@ -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 : ''}` + } + } +})) diff --git a/pages/course-detail/course-detail.json b/pages/course-detail/course-detail.json new file mode 100644 index 0000000..34bf9a8 --- /dev/null +++ b/pages/course-detail/course-detail.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "课程详情", + "backgroundColor": "#f7f8fc" +} diff --git a/pages/course-detail/course-detail.wxml b/pages/course-detail/course-detail.wxml new file mode 100644 index 0000000..0c26f0c --- /dev/null +++ b/pages/course-detail/course-detail.wxml @@ -0,0 +1,74 @@ + + + + {{course.coverEmoji}} + + {{course.title}} + + + + {{isFavorite ? '❤️' : '🤍'}} + + + + + + + + {{course.difficulty}} + {{course.lessons}}节课 + ⏱ {{course.duration}} + + {{course.desc}} + + + + + + 🎯 学习目标 + {{course.target}} + + + + 👤 适合人群 + {{course.suitable}} + + + + 🖌 所需工具 + {{course.tools}} + + + + + + 课程步骤({{course.steps.length}}步) + + + + + {{index + 1}} + + + {{item.title}} + + + + + + + + + + + + 已学到第{{progress.currentStep + 1}}步,继续加油! + + + ✅ 已完成本课,可重新学习 + + + + diff --git a/pages/course-detail/course-detail.wxss b/pages/course-detail/course-detail.wxss new file mode 100644 index 0000000..76b7492 --- /dev/null +++ b/pages/course-detail/course-detail.wxss @@ -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%; +} diff --git a/pages/home/home.js b/pages/home/home.js new file mode 100644 index 0000000..1481984 --- /dev/null +++ b/pages/home/home.js @@ -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' + } + } +})) diff --git a/pages/home/home.json b/pages/home/home.json new file mode 100644 index 0000000..e1a2f98 --- /dev/null +++ b/pages/home/home.json @@ -0,0 +1,6 @@ +{ + "navigationBarTitleText": "画画怎么画", + "navigationBarBackgroundColor": "#6C8CFF", + "navigationBarTextStyle": "white", + "backgroundColor": "#f7f8fc" +} diff --git a/pages/home/home.wxml b/pages/home/home.wxml new file mode 100644 index 0000000..abb43b5 --- /dev/null +++ b/pages/home/home.wxml @@ -0,0 +1,126 @@ + + + + + + 画画怎么画 + 从零开始,画出属于你的世界 🎨 + + + + + 🔍 + + + + + + + + + 零基础入门路径 + 按顺序学,稳稳起步 + + + + + 步骤 {{index + 1}} + {{item.icon}} + {{item.title}} + {{item.desc}} + + + + + + + + + 课程分类 + + + + {{index === 0 ? '✏️' : index === 1 ? '🧍' : index === 2 ? '🐾' : index === 3 ? '🌿' : index === 4 ? '🌄' : '📐'}} + {{item}} + + + + + + + + 今日推荐 + + + + + {{item.coverEmoji}} + + + {{item.title}} + {{item.desc}} + + {{item.difficulty}} + {{item.lessons}}节课 · {{item.duration}} + + + + + + + + + + 继续学习 + + + + + {{item.coverEmoji}} + + + {{item.courseTitle}} + 上次学习:第{{item.stepIndex + 1}}步 + + + + + + + + + + diff --git a/pages/home/home.wxss b/pages/home/home.wxss new file mode 100644 index 0000000..c3afacb --- /dev/null +++ b/pages/home/home.wxss @@ -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; +} diff --git a/pages/practice/practice.js b/pages/practice/practice.js new file mode 100644 index 0000000..6ec4483 --- /dev/null +++ b/pages/practice/practice.js @@ -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' } + } +})) diff --git a/pages/practice/practice.json b/pages/practice/practice.json new file mode 100644 index 0000000..54833f6 --- /dev/null +++ b/pages/practice/practice.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "每日练习", + "backgroundColor": "#f7f8fc" +} diff --git a/pages/practice/practice.wxml b/pages/practice/practice.wxml new file mode 100644 index 0000000..1a567fd --- /dev/null +++ b/pages/practice/practice.wxml @@ -0,0 +1,62 @@ + + + + + + + + + + {{item}} + + + + + + + + {{item.icon}} + + + + {{item.title}} + + ✓ 已完成 + + + {{item.type}} + {{item.desc}} + + {{item.difficulty}} + ⏱ {{item.duration}} + + + + + + + 📝 + 没有该难度的练习 + + + + diff --git a/pages/practice/practice.wxss b/pages/practice/practice.wxss new file mode 100644 index 0000000..48f2348 --- /dev/null +++ b/pages/practice/practice.wxss @@ -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; +} diff --git a/pages/profile/profile.js b/pages/profile/profile.js new file mode 100644 index 0000000..b3c1b69 --- /dev/null +++ b/pages/profile/profile.js @@ -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' } + } +})) diff --git a/pages/profile/profile.json b/pages/profile/profile.json new file mode 100644 index 0000000..47bd3e5 --- /dev/null +++ b/pages/profile/profile.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "我的", + "backgroundColor": "#f7f8fc" +} diff --git a/pages/profile/profile.wxml b/pages/profile/profile.wxml new file mode 100644 index 0000000..7d405d6 --- /dev/null +++ b/pages/profile/profile.wxml @@ -0,0 +1,111 @@ + + + + + 🎨 + + + + + + + + {{completedCount}} + 已学课程 + + + + {{practiceCount}} + 完成练习 + + + + {{favoriteCourses.length}} + 收藏课程 + + + + + + + 收藏课程 + + + + + {{item.coverEmoji}} + + {{item.title}} + + + + 暂无收藏,去课程详情页收藏你喜欢的课程吧 + + + + + + + 最近作品 + + + + + + {{item.courseTitle}} + + + + + + + + + + 🔔 + 学习提醒 + + + {{settings && settings.reminderEnabled ? '已开启' : '已关闭'}} + + + + + + + + 帮助说明 + + + + + + + 🔒 + 隐私说明 + + + + + + + diff --git a/pages/profile/profile.wxss b/pages/profile/profile.wxss new file mode 100644 index 0000000..bec48d9 --- /dev/null +++ b/pages/profile/profile.wxss @@ -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; +} diff --git a/pages/study-step/study-step.js b/pages/study-step/study-step.js new file mode 100644 index 0000000..3346853 --- /dev/null +++ b/pages/study-step/study-step.js @@ -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 : ''}` + } + } +})) diff --git a/pages/study-step/study-step.json b/pages/study-step/study-step.json new file mode 100644 index 0000000..71a3b8d --- /dev/null +++ b/pages/study-step/study-step.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "学习", + "backgroundColor": "#f7f8fc" +} diff --git a/pages/study-step/study-step.wxml b/pages/study-step/study-step.wxml new file mode 100644 index 0000000..8a85e9b --- /dev/null +++ b/pages/study-step/study-step.wxml @@ -0,0 +1,55 @@ + + + + + + + + 第 {{currentStepIndex + 1}} 步 + 共 {{totalSteps}} 步 + + + + + + + + 步骤 {{currentStepIndex + 1}} + {{currentStep.title}} + + + + + + {{currentStep.imageEmoji}} + 示意图 + + + + + + 📖 步骤说明 + {{currentStep.desc}} + + + + + {{currentStep.tip}} + + + + + + + + + + + diff --git a/pages/study-step/study-step.wxss b/pages/study-step/study-step.wxss new file mode 100644 index 0000000..eb387e3 --- /dev/null +++ b/pages/study-step/study-step.wxss @@ -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; +} diff --git a/pages/work-submit/work-submit.js b/pages/work-submit/work-submit.js new file mode 100644 index 0000000..24a1a72 --- /dev/null +++ b/pages/work-submit/work-submit.js @@ -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' + } + } +})) diff --git a/pages/work-submit/work-submit.json b/pages/work-submit/work-submit.json new file mode 100644 index 0000000..9a3024d --- /dev/null +++ b/pages/work-submit/work-submit.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "作品记录", + "backgroundColor": "#f7f8fc" +} diff --git a/pages/work-submit/work-submit.wxml b/pages/work-submit/work-submit.wxml new file mode 100644 index 0000000..3f0ab93 --- /dev/null +++ b/pages/work-submit/work-submit.wxml @@ -0,0 +1,91 @@ + + + + 🎉 + 完成本课! + {{course ? course.title : ''}} · 学完啦 + + + + + 示例作品 + 我的作品 + 对比查看 + + + + + + + + {{course ? course.coverEmoji : '🎨'}} + 范例参考图 + + 这是本课的参考范例,上传你的作品后可以对比查看 + + + + + + 📷 + 点击上传我的作品 + 支持相册选择或拍照 + + + + 点击重新上传 + + + + + + + + 范例 + + {{course ? course.coverEmoji : '🎨'}} + + + VS + + 我的作品 + + 📷 + 点击上传 + + + + + + + 太棒了!每一笔都是进步的证明 💪 + + + + + + + + + + ✅ 已保存记录 + + + + diff --git a/pages/work-submit/work-submit.wxss b/pages/work-submit/work-submit.wxss new file mode 100644 index 0000000..4527f4c --- /dev/null +++ b/pages/work-submit/work-submit.wxss @@ -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; +} diff --git a/project.config.json b/project.config.json new file mode 100644 index 0000000..960d67c --- /dev/null +++ b/project.config.json @@ -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": {} +} \ No newline at end of file diff --git a/project.private.config.json b/project.private.config.json new file mode 100644 index 0000000..250e19e --- /dev/null +++ b/project.private.config.json @@ -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": {} +} \ No newline at end of file diff --git a/sitemap.json b/sitemap.json new file mode 100644 index 0000000..ca02add --- /dev/null +++ b/sitemap.json @@ -0,0 +1,7 @@ +{ + "desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html", + "rules": [{ + "action": "allow", + "page": "*" + }] +} \ No newline at end of file diff --git a/utils/api.js b/utils/api.js new file mode 100644 index 0000000..5bd609f --- /dev/null +++ b/utils/api.js @@ -0,0 +1,10 @@ +/** + * API 接口定义 + * 画画怎么画 — 纯本地存储项目,无后端 API 调用 + * 所有数据操作通过 utils/storage.js 进行 + */ + +// 本项目为本地存储型工具,所有接口通过 storage.js 实现 +// 如后续需要接入后端,在此统一定义接口 + +module.exports = {} diff --git a/utils/auth.js b/utils/auth.js new file mode 100644 index 0000000..4d6437b --- /dev/null +++ b/utils/auth.js @@ -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 +} diff --git a/utils/config.js b/utils/config.js new file mode 100644 index 0000000..b136e81 --- /dev/null +++ b/utils/config.js @@ -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 + } +} diff --git a/utils/data.js b/utils/data.js new file mode 100644 index 0000000..a3a3278 --- /dev/null +++ b/utils/data.js @@ -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) + } +} diff --git a/utils/httpClient.js b/utils/httpClient.js new file mode 100644 index 0000000..36c1ddc --- /dev/null +++ b/utils/httpClient.js @@ -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 +} diff --git a/utils/index.js b/utils/index.js new file mode 100644 index 0000000..4922715 --- /dev/null +++ b/utils/index.js @@ -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 +} diff --git a/utils/request.js b/utils/request.js new file mode 100644 index 0000000..7d8a16b --- /dev/null +++ b/utils/request.js @@ -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 +} diff --git a/utils/storage.js b/utils/storage.js new file mode 100644 index 0000000..9a63280 --- /dev/null +++ b/utils/storage.js @@ -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 +}