first commit

This commit is contained in:
honghefly 2026-01-14 15:33:15 +08:00
commit f457c1d04c
96 changed files with 5827 additions and 0 deletions

219
app.js Normal file
View File

@ -0,0 +1,219 @@
import { injectApp, waitLogin, gatewayHttpClient } from '@jdmini/api'
App(injectApp()({
globalData: {
userInfo: null,
openid: null,
wxUserInfo: null,
inviterId: null // 邀请人ID
},
async onLaunch(options) {
// 保存邀请人ID
if (options && options.query && options.query.inviter) {
this.globalData.inviterId = options.query.inviter
wx.setStorageSync('inviterId', options.query.inviter)
}
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();
}
}
});
});
}
});
}
const accountInfo = wx.getExtConfigSync()
//console.log('#1',accountInfo)
const appId = accountInfo.appId;
// 等待登陆完成以后请求自动携带token
await waitLogin()
// 三方登录本地缓存
console.log(wx.getStorageSync('jdwx-userinfo'))
const wxUserInfo = wx.getStorageSync('jdwx-userinfo');
wx.setStorageSync('sfUserId', wxUserInfo.id)
wx.setStorageSync('appId',appId)
// 保存到全局
this.globalData.openid = wxUserInfo.openId;
this.globalData.wxUserInfo = wxUserInfo;
const userId = wx.getStorageSync('userId');
if (userId) {
console.log('用户已登录', userId);
} else {
console.log('用户未设置个人信息,需要跳转登录页');
}
//this.getqrcode('logo=1')
},
// 生成小程序二维码
async getqrcode(value) {
try {
// 构建场景值仓库ID和仓库code
const scene = value;
console.log('生成二维码请求参数:', {
scene,
page: "pages/index/index"
});
const result = await gatewayHttpClient.request('/wx/v1/api/app/qrcode', 'POST', {
"scene": scene,
"page": "pages/index/index", // 跳转到index页面
"check_path": true,
"env_version": "release"
},{
responseType: 'arraybuffer'
});
console.log('二维码接口返回:', result);
console.log('返回数据类型:', typeof result);
console.log('是否为ArrayBuffer:', result instanceof ArrayBuffer);
// 当设置 responseType: 'arraybuffer' 时,返回的直接就是 ArrayBuffer
if (result instanceof ArrayBuffer) {
console.log('检测到 ArrayBuffer转换为 base64');
const base64Data = wx.arrayBufferToBase64(result);
console.log('转换后的 base64 数据长度:', base64Data?.length);
console.log('base64 数据前50个字符:', base64Data?.substring(0, 50));
return {
success: true,
data: base64Data // 二维码图片数据(base64字符串)
};
} else {
console.error('二维码生成失败返回数据不是ArrayBuffer:', result);
return {
success: false,
message: '生成二维码失败:数据格式错误'
};
}
} catch (error) {
console.error('生成二维码异常:', error);
return {
success: false,
message: error.message || '生成二维码失败'
};
}
},
//内容安全
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 {
console.error('内容检测API调用失败:', data)
return 2
}
} catch (error) {
console.error('checkdata函数执行错误:', error)
return 2
}
},
// 轮询检查图片是否合规
//imgurl 为本地图片地址
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)); // 间隔1秒
const passcode = await this.checkSafetyResults(checkid);
// 检查具体的错误码
switch (passcode) {
case 100:
wx.hideLoading();
return upfileData.data;
case 20001:
wx.hideLoading();
await gatewayHttpClient.deleteFile(upfileData.data.id);
this.showwarning('图片时政内容,请重新选择');
return null;
case 20002:
wx.hideLoading();
await gatewayHttpClient.deleteFile(upfileData.data.id);
this.showwarning('图片含有色情内容,请重新选择');
return null;
case 20006:
wx.hideLoading();
await gatewayHttpClient.deleteFile(upfileData.data.id);
this.showwarning('图片含有违法犯罪内容,请重新选择');
return null;
case 21000:
wx.hideLoading();
await gatewayHttpClient.deleteFile(upfileData.data.id);
this.showwarning('图片非法内容,请重新选择');
return null;
default:
break
}
retryCount++;
}
// 5次超时返回失败
wx.hideLoading();
await gatewayHttpClient.deleteFile(upfileData.data.id)//删除图片
wx.showToast({
title: '图片检查超时,请重试',
icon: 'none'
});
return null;
} catch (error) {
wx.hideLoading();
console.error('图片检查失败:', error);
wx.showToast({
title: '检查失败,请重试',
icon: 'none'
});
return null;
}
},
showwarning(txt){
wx.showModal({
title: '提示',
content: txt,
showCancel: false,
success: () => {
}
});
}
}))

53
app.json Normal file
View File

@ -0,0 +1,53 @@
{
"pages": [
"pages/index/index",
"pages/category/category",
"pages/category-detail/category-detail",
"pages/detail/detail",
"pages/download/download",
"pages/mine/mine",
"pages/search/search",
"pages/login/login"
],
"window": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "儿童练习表",
"navigationBarBackgroundColor": "#ffffff"
},
"tabBar": {
"color": "#999999",
"selectedColor": "#4CAF50",
"backgroundColor": "#ffffff",
"borderStyle": "black",
"list": [
{
"pagePath": "pages/index/index",
"text": "首页",
"iconPath": "images/tabbar/home.png",
"selectedIconPath": "images/tabbar/home-active.png"
},
{
"pagePath": "pages/category/category",
"text": "分类",
"iconPath": "images/tabbar/category.png",
"selectedIconPath": "images/tabbar/category-active.png"
},
{
"pagePath": "pages/download/download",
"text": "下载",
"iconPath": "images/tabbar/download.png",
"selectedIconPath": "images/tabbar/download-active.png"
},
{
"pagePath": "pages/mine/mine",
"text": "我的",
"iconPath": "images/tabbar/mine.png",
"selectedIconPath": "images/tabbar/mine-active.png"
}
]
},
"style": "v2",
"componentFramework": "glass-easel",
"sitemapLocation": "sitemap.json",
"lazyCodeLoading": "requiredComponents"
}

60
app.wxss Normal file
View File

@ -0,0 +1,60 @@
/* 全局样式 */
page {
background-color: #f5f5f5;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 28rpx;
color: #333;
box-sizing: border-box;
}
/* 清除默认边距 */
view, text, image {
box-sizing: border-box;
}
/* 常用flex布局 */
.flex {
display: flex;
}
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.flex-wrap {
display: flex;
flex-wrap: wrap;
}
.flex-column {
display: flex;
flex-direction: column;
}
/* 文本省略 */
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.text-ellipsis-2 {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
/* 安全区域底部内边距 */
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom);
}

55
images/README.md Normal file
View File

@ -0,0 +1,55 @@
# 图片资源说明
## 需要准备的图片资源
### TabBar 图标 (images/tabbar/)
- home.png / home-active.png - 首页图标
- category.png / category-active.png - 分类图标
- download.png / download-active.png - 下载图标
- mine.png / mine-active.png - 我的图标
建议尺寸: 81x81px (实际显示约 27x27pt)
### 功能图标 (images/icons/)
- search.png - 搜索图标
- arrow-right.png - 右箭头
- preview.png - 预览图标
- download.png - 下载图标
- print.png - 打印图标
- open.png - 打开文件图标
- delete.png - 删除图标
- clear.png - 清除图标
- empty.png - 空状态图标
- empty-download.png - 下载空状态图标
- empty-search.png - 搜索空状态图标
- download-menu.png - 菜单下载图标
- share.png - 分享图标
- feedback.png - 反馈图标
- about.png - 关于图标
建议尺寸: 48x48px 或 64x64px
### Banner 图片 (images/banner/)
- banner1.png - 轮播图1
- banner2.png - 轮播图2
- banner3.png - 轮播图3
建议尺寸: 690x280rpx (可适当调整)
### 默认图片
- default-avatar.png - 默认头像
建议尺寸: 120x120px
## 图标建议
可使用以下免费图标库:
1. iconfont.cn (阿里图标库)
2. Material Design Icons
3. Feather Icons
4. Heroicons
图标风格建议:
- 线性图标,线宽 2px
- 颜色: 未选中 #999999, 选中 #4CAF50
- 格式: PNG (带透明背景) 或 SVG

BIN
images/icons/about.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 779 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 566 B

BIN
images/icons/clear.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 619 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 531 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
images/icons/search.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
images/icons/share.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 811 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 B

BIN
images/tabbar/category.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 725 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 B

BIN
images/tabbar/download.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
images/tabbar/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
images/tabbar/mine.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1 @@
/d/code/youerqimeng-server/miniapp/images

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

@ -0,0 +1,295 @@
// Generated by dts-bundle v0.7.3
declare module '@jdmini/api' {
import { onLoginReady, waitLogin } from '@jdmini/api/app';
import HttpClient, { gatewayHttpClient, baseHttpClient, apiHttpClient } from '@jdmini/api/httpClient';
import { injectApp, injectPage, injectComponent, hijackApp, hijackAllPage } from '@jdmini/api/injector';
import adManager from '@jdmini/api/adManager';
export { onLoginReady, waitLogin, injectApp, injectPage, injectComponent, hijackApp, hijackAllPage, gatewayHttpClient, baseHttpClient, apiHttpClient, HttpClient, adManager, };
}
declare module '@jdmini/api/app' {
export interface AppOptions {
gatewayUrl?: string;
baseUrl?: string;
apiUrl?: string;
}
export interface PageOptions {
showInterstitialAd?: boolean;
}
export function initApp(options?: AppOptions): Promise<void>;
export function showPage(options: PageOptions | undefined, pageId: string): Promise<void>;
export const checkTokenValid: () => boolean;
/**
*
* @param {Function} callback -
* @returns {void}
*/
export function onLoginReady(callback: (...args: any[]) => void): void;
/**
*
* @returns {Promise<void>}
*/
export function waitLogin(): Promise<void>;
export function login(): Promise<void>;
export function fetchEchoData(): Promise<void>;
export function trackVisit(): Promise<void>;
}
declare module '@jdmini/api/httpClient' {
import { HttpClientOptions, RequestOptions, ApiResponse } from '@jdmini/api/types';
class HttpClient {
constructor({ baseURL, timeout }: HttpClientOptions);
setBaseURL(baseURL: string): void;
/**
*
* @param {string} path
* @param {string} method , GET
* @param {Object} data , {}
* @param {Object} options wx.request的其他配置, {}
* @returns {Promise<Object>} Promise对象
*/
request<T = any>(path: string, method?: WechatMiniprogram.RequestOption['method'], data?: Record<string, any>, options?: RequestOptions): Promise<ApiResponse<T>>;
/**
*
* @param {string} filePath
* @param {Object} data , {}
* @param {'avatar' | 'file'} type , 'file'
* @returns {Promise<Object>} Promise对象
*/
uploadFile<T = any>(filePath: string, data?: Record<string, any>, type?: 'avatar' | 'file'): Promise<ApiResponse<T>>;
/**
*
* @param {string} filePath
* @param {Object} data , {}
* @param {'avatar' | 'file'} type , 'file'
* @returns {Promise<Object>} Promise对象
*/
upload<T = any>(path: string, filePath: string, data?: Record<string, any>): Promise<ApiResponse<T>>;
/**
*
* @param {number} fileId id
* @returns {Promise<Object>} Promise对象
*/
deleteFile(fileId: number): Promise<ApiResponse<null>>;
/**
*
* @param {string} filePath
* @returns {Promise<Object>} Promise对象
*/
uploadAvatar<T = any>(filePath: string): Promise<ApiResponse<T>>;
}
export const gatewayHttpClient: HttpClient;
export const baseHttpClient: HttpClient;
export const apiHttpClient: HttpClient;
export default HttpClient;
}
declare module '@jdmini/api/injector' {
interface AppConfig {
onLaunch?: (...args: any[]) => void | Promise<void>;
[key: string]: any;
}
interface PageConfig {
onShow?: (...args: any[]) => void | Promise<void>;
[key: string]: any;
}
interface ComponentConfig {
methods?: {
onLoad?: (...args: any[]) => void | Promise<void>;
onShow?: (...args: any[]) => void | Promise<void>;
[key: string]: any;
};
[key: string]: any;
}
interface InjectAppOptions {
gatewayUrl?: string;
baseUrl?: string;
apiUrl?: string;
}
interface InjectPageOptions {
showInterstitialAd?: boolean;
}
/**
*
* @param {Object} options -
* @param {string} [options.gatewayUrl] - 使CONFIG.API.GATEWAY_URL
* @param {string} [options.baseUrl] - 使CONFIG.API.BASE_URL
* @param {string} [options.apiUrl] - api地址使CONFIG.API.API_URL
* @returns {Function}
*/
export function injectApp(options?: InjectAppOptions): (appConfig: AppConfig) => AppConfig;
/**
*
* @param {InjectPageOptions} options -
* @param {boolean} [options.showInterstitialAd] - onShow显示插屏广告
* @returns {Function}
*/
export function injectPage(options?: InjectPageOptions): (pageConfig?: PageConfig) => PageConfig;
/**
*
* @param {InjectPageOptions} options -
* @param {boolean} [options.showInterstitialAd] - onShow显示插屏广告
* @returns {Function}
*/
export function injectComponent(options?: InjectPageOptions): (pageConfig?: PageConfig) => ComponentConfig;
/**
* App
* @param {InjectAppOptions} options -
* @param {string} [options.gatewayUrl] - 使CONFIG.API.GATEWAY_URL
* @param {string} [options.baseUrl] - 使CONFIG.API.BASE_URL
* @param {string} [options.apiUrl] - api地址使CONFIG.API.API_URL
* @returns {void}
*/
export const hijackApp: (options?: InjectAppOptions) => void;
/**
* Page
* @param {InjectPageOptions} options -
* @param {boolean} [options.showInterstitialAd] - onShow显示插屏广告
* @returns {void}
*/
export const hijackAllPage: (options?: InjectPageOptions) => void;
export {};
}
declare module '@jdmini/api/adManager' {
import { AdData, LinkData, TopData } from '@jdmini/api/types';
type Ads = Partial<Record<AdData['appPage'], AdData['ads'][0]['adUnitId']>>;
class AdManager {
/**
* 广
*/
ads: Ads;
/**
*
*/
link: LinkData[];
/**
* 广
*/
top: TopData | null;
constructor();
/**
* 广
* @param {Function} callback -
* @returns {void}
*/
onDataReady: (callback: (...args: any[]) => void) => void;
/**
* 广
* @returns {Promise<void>}
*/
waitAdData: () => Promise<void>;
/**
* 广
* @returns {void}
*/
init: () => void;
/**
* 广
* @returns {Promise<void>}
*/
createAndShowInterstitialAd: () => Promise<void>;
/**
* 广
* @param {any} context -
* @param {string} [pageId] - ID
* @returns {Promise<boolean>}
*/
createAndShowRewardedVideoAd: (context: any, pageId?: string) => Promise<boolean>;
}
const _default: AdManager;
export default _default;
}
declare module '@jdmini/api/types' {
export interface Config {
API: {
GATEWAY_URL: string;
BASE_URL: string;
API_URL: string;
};
APP: {
APP_ID: number;
LOGIN_MAX_RETRY: number;
};
HTTP: {
TIMEOUT: number;
};
DATA: {
PAGE_ID: string;
};
STORAGE_KEYS: {
TOKEN: string;
USER_INFO: string;
SPA_DATA: string;
LINK_DATA: string;
TOP_DATA: string;
};
EVENT_KEYS: {
LOGIN_SUCCESS: string;
AD_DATA_READY: string;
REWARDED_VIDEO_AD_CLOSE: string;
};
}
export interface HttpClientOptions {
baseURL: string;
timeout?: number;
}
export interface RequestOptions {
headers?: Record<string, string>;
[key: string]: any;
}
export interface LoginData {
appId: number;
code: string;
brand: string;
model: string;
platform: string;
}
export interface ApiResponse<T = any> {
code: number;
message?: string;
data?: T;
}
export interface UserInfo {
token: string;
user: {
id: number;
name: string;
avatar: string;
openId: string;
};
}
export interface EchoData {
isPublished: boolean;
spads: AdData[];
links: LinkData[];
top: TopData;
version: number;
}
export interface AdData {
id: number;
status: number;
appPage: 'banner' | 'custom' | 'video' | 'interstitial' | 'rewarded';
ads: {
id: number;
type: number;
adId: number;
adUnitId: string;
}[];
}
export interface LinkData {
appId: string;
appLogo: string;
linkName: string;
linkPage: string;
}
export interface TopData {
appId: string;
appLogo: string;
linkName: string;
appDsc: string;
}
}

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,22 @@
import { adManager } from '@jdmini/api'
Component({
properties: {
type: {
type: String,
value: 'custom' // 可选banner, video, custom
}
},
data: {
ads: {}
},
lifetimes: {
attached: function () {
adManager.onDataReady(() => {
this.setData({ ads: adManager.ads })
})
},
},
methods: {
}
})

View File

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

View File

@ -0,0 +1,5 @@
<view class="jdwx-ad-component">
<ad wx:if="{{type === 'banner' && ads.banner}}" class="jdwx-ad-item" unit-id="{{ads.banner}}"></ad>
<ad wx:if="{{type === 'video' && ads.video}}" class="jdwx-ad-item" ad-type="video" unit-id="{{ads.video}}"></ad>
<ad-custom wx:if="{{type === 'custom' && ads.custom}}" class="jdwx-ad-item" unit-id="{{ads.custom}}"></ad-custom>
</view>

View File

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

View File

@ -0,0 +1,37 @@
import { adManager } from '@jdmini/api'
Component({
properties: {
},
data: {
link: [],
top: {},
},
pageLifetimes: {
show: function () {
adManager.onDataReady(() => {
this.setData({
link: adManager.link,
top: adManager.top
})
})
},
},
methods: {
gotopLink: function () {
wx.vibrateShort()
wx.openEmbeddedMiniProgram({
appId: this.data.top.appId,
path: this.data.top.linkPage
});
},
goLink: function (e) {
let index = e.currentTarget.id
wx.vibrateShort()
wx.openEmbeddedMiniProgram({
appId: this.data.link[index].appId,
path: this.data.link[index].linkPage
});
},
}
})

View File

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

View File

@ -0,0 +1,11 @@
<view class="jdwx-link-component">
<view wx:if="{{top.appId}}" class="jdwx-applink-top" bindtap="gotopLink">
<view><image src="{{top.appLogo}}" class="jdwx-applink-icon" /> </view>
<view ><text class="jdwx-applink-top-linkname">{{top.linkName}}</text>
<text class="jdwx-applink-top-text">{{top.appDsc}}</text> </view>
</view>
<view id="{{bindex}}" bindtap="goLink" wx:for="{{link}}" wx:for-index="bindex" wx:key="index" class="jdwx-applink-list">
<image src="{{item.appLogo}}" class="jdwx-applink-icon" />
<text class="jdwx-applink-text">{{item.linkName}}</text>
</view>
</view>

View File

@ -0,0 +1,63 @@
/* 页面容器 */
.jdwx-link-component {
/* background-image: linear-gradient(to right, #4F9863, #4F9863); */
background-attachment: fixed;
background-size: cover; /* 确保背景图像覆盖整个元素 */
/* height: 100vh; */
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding: 20rpx;
box-sizing: border-box;
overflow: auto; /* 允许内容滚动 */
}
/* 列表项样式 */
.jdwx-applink-list {
display: flex;
align-items: center;
width: 95%;
/* 假设我们想要每个view的高度约为屏幕高度的1/8 */
/* 使用小程序的wx.getSystemInfo API动态计算并设置这个值会更准确 */
height: calc((100vh - 40rpx) / 10); /* 减去容器padding的影响 */
padding: 20rpx;
background-color: rgba(248, 250, 252, 1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border-radius: 8rpx;
margin-bottom: 10rpx;
}
.jdwx-applink-top {
display: flex;
align-items: center;
width: 95%;
/* 假设我们想要每个view的高度约为屏幕高度的1/8 */
/* 使用小程序的wx.getSystemInfo API动态计算并设置这个值会更准确 */
height: calc((100vh - 40rpx) / 6); /* 减去容器padding的影响 */
padding: 20rpx;
border-radius: 8rpx;
margin-bottom: 30rpx;
background-color: rgba(248, 250, 252, 1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.jdwx-applink-top-linkname{
display: flex;
font-size: 36rpx;
color: rgb(39, 37, 37);
padding-bottom: 10rpx;
}
/* 图标样式 */
.jdwx-applink-icon {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
margin-right: 50rpx;
margin-left: 30rpx;
}
/* 文本样式 */
.jdwx-applink-text {
flex: 1;
font-size: 32rpx;
color: rgb(39, 37, 37);
}

20
node_modules/.package-lock.json generated vendored Normal file
View File

@ -0,0 +1,20 @@
{
"name": "template",
"lockfileVersion": 3,
"requires": true,
"packages": {
"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"
}
}
}
}

252
node_modules/@jdmini/api/README.md generated vendored Normal file
View File

@ -0,0 +1,252 @@
## 安装/更新
1、终端使用命令安装 npm 包
```bash
npm install --save @jdmini/api@latest
```
> ```bash
> npm install --save @jdmini/components@latest
> ```
2、在小程序开发者工具中菜单选择工具 -> 构建 npm
## 使用
```js
import {
onLoginReady,
waitLogin,
injectApp,
injectPage,
injectComponent,
hijackApp,
hijackAllPage,
gatewayHttpClient,
baseHttpClient,
apiHttpClient,
HttpClient,
adManager,
} from '@jdmini/api'
```
### `waitLogin`/`onLoginReady` - 确保登录完成
- 同步的写法
```js
async function onLoad() {
await waitLogin()
// 此处代码将在已登录或登陆完成后执行。请求将自动携带Token
await gatewayHttpClient.request('/xxx', 'GET', {})
}
```
- 异步的写法
```js
function onLoad() {
onLoginReady(() => {
// 此处代码将在已登录或登陆完成后执行。请求将自动携带Token
gatewayHttpClient.request('/xxx', 'GET', {})
})
}
```
### `injectApp` - 向App注入基础代码
- 注入之后实现自动登录、广告初始化等功能
```js
// app.js
App(injectApp()({
// 业务代码
onLaunch() {
}
}))
```
### `injectPage` - 向Page注入基础代码
- 注入之后实现页面自动统计、自动展示插屏广告以及激励视频广告的调用支持
- 参数:
- showInterstitialAd: 是否自动展示插屏广告
```js
// pages/xxx/xxx.js
Page(injectPage({
showInterstitialAd: true
})({
// 业务代码
onLoad() {
}
}))
```
### `injectComponent` - 向Component注入基础代码
- 适用于使用Component构造页面的场景
- 注入之后实现页面自动统计、自动展示插屏广告以及激励视频广告的调用支持
- 参数:
- showInterstitialAd: 是否自动展示插屏广告
```js
// pages/xxx/xxx.js
Component(injectComponent({
showInterstitialAd: true
})({
// 业务代码
methods: {
onLoad() {
}
}
}))
```
### `hijackApp` - 劫持全局App方法注入基础代码
- 在不方便使用injectApp时使用如解包后代码复杂难以修改App调用
- 此方法会修改全局App方法存在未知风险使用时请进行完整测试
- 不可与injectApp同时使用
```js
// app.js
hijackApp()
```
### `hijackAllPage` - 劫持全局Page方法注入基础代码
- 在不方便使用injectPage/injectComponent时使用如解包后代码复杂难以修改Page/Component调用
- 此方法会修改全局Page方法并影响所有的页面存在未知风险使用时请进行完整测试
- 参数同injectPage/injectComponent方法不可与这些方法同时使用
```js
// app.js
hijackAllPage({
showInterstitialAd: true
})
```
### `gatewayHttpClient` - 网关API调用封装
- 同步的写法
```js
async function onLoad() {
try {
// 网关请求。参数路径、方法、数据、其他选项如headers、responseType
const data = await gatewayHttpClient.request(path, method, dataoptions)
// 头像上传。参数:文件路径
const data = await gatewayHttpClient.uploadAvatar(filePath)
// 文件上传。参数:文件路径、数据
const data = await gatewayHttpClient.uploadFile(filePath, data)
// 文件删除。参数文件ID
const data = await gatewayHttpClient.deleteFile(fileId)
} catch(err) {
// 响应HTTP状态码非200时自动showToast并抛出异常
}
}
```
- 所有方法均支持异步的写法
```js
function onLoad() {
gatewayHttpClient.request('/xxx')
.then(data => {
console.log(data)
})
.catch(err => {})
}
```
### `baseHttpClient`/`apiHttpClient` - 为老版本兼容保留,不推荐使用
### `HttpClient` - API底层类用于封装自定义请求
- 示例:封装一个百度的请求客户端,并调用百度搜索
```js
const baiduHttpClient = new HttpClient({
baseURL: 'https://www.baidu.com',
timeout: 5000,
});
baiduHttpClient.request('/s', 'GET', { wd: '测试' }, { responseType: 'text' })
.then(data => console.log(data))
```
### `adManager` - 广告管理器
- 确保广告数据加载完成,支持同步/异步的写法
```js
// 同步的写法
async function onLoad() {
await adManager.waitAdData()
// 此处代码将在广告数据加载完成后执行
await adManager.createAndShowInterstitialAd()
}
// 异步的写法
function onLoad () {
adManager.onDataReady(() => {
// 此处代码将在广告数据加载完成后执行
adManager.createAndShowInterstitialAd()
})
}
```
- 广告数据
```js
// 格式化之后的广告数据对象,如{banner: "adunit-f7709f349de05edc", custom: "adunit-34c76b0c3e4a6ed0", ...}
const ads = adManager.ads
// 友情链接顶部广告数据
const top = adManager.top
// 友情链接数据
const link = adManager.link
```
- 创建并展示插屏广告
```js
function onLoad() {
adManager.createAndShowInterstitialAd()
}
```
- 创建并展示激励视频广告
- 传入当前页面的上下文this返回用户是否已看完广告
- 由于微信的底层限制需要先在调用的页面上进行injectPage注入且该方法必须放在用户的点击事件里调用
- 使用示例可参考[jdwx-demo](https://code.miniappapi.com/wx/jdwx-demo)
```js
// 同步的写法
page({
async handleClick() {
const isEnded = await adManager.createAndShowRewardedVideoAd(this)
}
})
// 异步的写法
page({
handleClick() {
adManager.createAndShowRewardedVideoAd(this).then((isEnded) => {
})
}
})
```

295
node_modules/@jdmini/api/miniprogram_dist/index.d.ts generated vendored Normal file
View File

@ -0,0 +1,295 @@
// Generated by dts-bundle v0.7.3
declare module '@jdmini/api' {
import { onLoginReady, waitLogin } from '@jdmini/api/app';
import HttpClient, { gatewayHttpClient, baseHttpClient, apiHttpClient } from '@jdmini/api/httpClient';
import { injectApp, injectPage, injectComponent, hijackApp, hijackAllPage } from '@jdmini/api/injector';
import adManager from '@jdmini/api/adManager';
export { onLoginReady, waitLogin, injectApp, injectPage, injectComponent, hijackApp, hijackAllPage, gatewayHttpClient, baseHttpClient, apiHttpClient, HttpClient, adManager, };
}
declare module '@jdmini/api/app' {
export interface AppOptions {
gatewayUrl?: string;
baseUrl?: string;
apiUrl?: string;
}
export interface PageOptions {
showInterstitialAd?: boolean;
}
export function initApp(options?: AppOptions): Promise<void>;
export function showPage(options: PageOptions | undefined, pageId: string): Promise<void>;
export const checkTokenValid: () => boolean;
/**
*
* @param {Function} callback -
* @returns {void}
*/
export function onLoginReady(callback: (...args: any[]) => void): void;
/**
*
* @returns {Promise<void>}
*/
export function waitLogin(): Promise<void>;
export function login(): Promise<void>;
export function fetchEchoData(): Promise<void>;
export function trackVisit(): Promise<void>;
}
declare module '@jdmini/api/httpClient' {
import { HttpClientOptions, RequestOptions, ApiResponse } from '@jdmini/api/types';
class HttpClient {
constructor({ baseURL, timeout }: HttpClientOptions);
setBaseURL(baseURL: string): void;
/**
*
* @param {string} path
* @param {string} method , GET
* @param {Object} data , {}
* @param {Object} options wx.request的其他配置, {}
* @returns {Promise<Object>} Promise对象
*/
request<T = any>(path: string, method?: WechatMiniprogram.RequestOption['method'], data?: Record<string, any>, options?: RequestOptions): Promise<ApiResponse<T>>;
/**
*
* @param {string} filePath
* @param {Object} data , {}
* @param {'avatar' | 'file'} type , 'file'
* @returns {Promise<Object>} Promise对象
*/
uploadFile<T = any>(filePath: string, data?: Record<string, any>, type?: 'avatar' | 'file'): Promise<ApiResponse<T>>;
/**
*
* @param {string} filePath
* @param {Object} data , {}
* @param {'avatar' | 'file'} type , 'file'
* @returns {Promise<Object>} Promise对象
*/
upload<T = any>(path: string, filePath: string, data?: Record<string, any>): Promise<ApiResponse<T>>;
/**
*
* @param {number} fileId id
* @returns {Promise<Object>} Promise对象
*/
deleteFile(fileId: number): Promise<ApiResponse<null>>;
/**
*
* @param {string} filePath
* @returns {Promise<Object>} Promise对象
*/
uploadAvatar<T = any>(filePath: string): Promise<ApiResponse<T>>;
}
export const gatewayHttpClient: HttpClient;
export const baseHttpClient: HttpClient;
export const apiHttpClient: HttpClient;
export default HttpClient;
}
declare module '@jdmini/api/injector' {
interface AppConfig {
onLaunch?: (...args: any[]) => void | Promise<void>;
[key: string]: any;
}
interface PageConfig {
onShow?: (...args: any[]) => void | Promise<void>;
[key: string]: any;
}
interface ComponentConfig {
methods?: {
onLoad?: (...args: any[]) => void | Promise<void>;
onShow?: (...args: any[]) => void | Promise<void>;
[key: string]: any;
};
[key: string]: any;
}
interface InjectAppOptions {
gatewayUrl?: string;
baseUrl?: string;
apiUrl?: string;
}
interface InjectPageOptions {
showInterstitialAd?: boolean;
}
/**
*
* @param {Object} options -
* @param {string} [options.gatewayUrl] - 使CONFIG.API.GATEWAY_URL
* @param {string} [options.baseUrl] - 使CONFIG.API.BASE_URL
* @param {string} [options.apiUrl] - api地址使CONFIG.API.API_URL
* @returns {Function}
*/
export function injectApp(options?: InjectAppOptions): (appConfig: AppConfig) => AppConfig;
/**
*
* @param {InjectPageOptions} options -
* @param {boolean} [options.showInterstitialAd] - onShow显示插屏广告
* @returns {Function}
*/
export function injectPage(options?: InjectPageOptions): (pageConfig?: PageConfig) => PageConfig;
/**
*
* @param {InjectPageOptions} options -
* @param {boolean} [options.showInterstitialAd] - onShow显示插屏广告
* @returns {Function}
*/
export function injectComponent(options?: InjectPageOptions): (pageConfig?: PageConfig) => ComponentConfig;
/**
* App
* @param {InjectAppOptions} options -
* @param {string} [options.gatewayUrl] - 使CONFIG.API.GATEWAY_URL
* @param {string} [options.baseUrl] - 使CONFIG.API.BASE_URL
* @param {string} [options.apiUrl] - api地址使CONFIG.API.API_URL
* @returns {void}
*/
export const hijackApp: (options?: InjectAppOptions) => void;
/**
* Page
* @param {InjectPageOptions} options -
* @param {boolean} [options.showInterstitialAd] - onShow显示插屏广告
* @returns {void}
*/
export const hijackAllPage: (options?: InjectPageOptions) => void;
export {};
}
declare module '@jdmini/api/adManager' {
import { AdData, LinkData, TopData } from '@jdmini/api/types';
type Ads = Partial<Record<AdData['appPage'], AdData['ads'][0]['adUnitId']>>;
class AdManager {
/**
* 广
*/
ads: Ads;
/**
*
*/
link: LinkData[];
/**
* 广
*/
top: TopData | null;
constructor();
/**
* 广
* @param {Function} callback -
* @returns {void}
*/
onDataReady: (callback: (...args: any[]) => void) => void;
/**
* 广
* @returns {Promise<void>}
*/
waitAdData: () => Promise<void>;
/**
* 广
* @returns {void}
*/
init: () => void;
/**
* 广
* @returns {Promise<void>}
*/
createAndShowInterstitialAd: () => Promise<void>;
/**
* 广
* @param {any} context -
* @param {string} [pageId] - ID
* @returns {Promise<boolean>}
*/
createAndShowRewardedVideoAd: (context: any, pageId?: string) => Promise<boolean>;
}
const _default: AdManager;
export default _default;
}
declare module '@jdmini/api/types' {
export interface Config {
API: {
GATEWAY_URL: string;
BASE_URL: string;
API_URL: string;
};
APP: {
APP_ID: number;
LOGIN_MAX_RETRY: number;
};
HTTP: {
TIMEOUT: number;
};
DATA: {
PAGE_ID: string;
};
STORAGE_KEYS: {
TOKEN: string;
USER_INFO: string;
SPA_DATA: string;
LINK_DATA: string;
TOP_DATA: string;
};
EVENT_KEYS: {
LOGIN_SUCCESS: string;
AD_DATA_READY: string;
REWARDED_VIDEO_AD_CLOSE: string;
};
}
export interface HttpClientOptions {
baseURL: string;
timeout?: number;
}
export interface RequestOptions {
headers?: Record<string, string>;
[key: string]: any;
}
export interface LoginData {
appId: number;
code: string;
brand: string;
model: string;
platform: string;
}
export interface ApiResponse<T = any> {
code: number;
message?: string;
data?: T;
}
export interface UserInfo {
token: string;
user: {
id: number;
name: string;
avatar: string;
openId: string;
};
}
export interface EchoData {
isPublished: boolean;
spads: AdData[];
links: LinkData[];
top: TopData;
version: number;
}
export interface AdData {
id: number;
status: number;
appPage: 'banner' | 'custom' | 'video' | 'interstitial' | 'rewarded';
ads: {
id: number;
type: number;
adId: number;
adUnitId: string;
}[];
}
export interface LinkData {
appId: string;
appLogo: string;
linkName: string;
linkPage: string;
}
export interface TopData {
appId: string;
appLogo: string;
linkName: string;
appDsc: string;
}
}

4
node_modules/@jdmini/api/miniprogram_dist/index.js generated vendored Normal file

File diff suppressed because one or more lines are too long

24
node_modules/@jdmini/api/package.json generated vendored Normal file
View File

@ -0,0 +1,24 @@
{
"name": "@jdmini/api",
"version": "1.0.10",
"main": "miniprogram_dist/index.js",
"files": [
"miniprogram_dist"
],
"scripts": {
"build": "webpack",
"pub": "npm run build && npm publish --access public",
"build:js": "tsc --project tsconfig_tsc.json"
},
"miniprogram": "miniprogram_dist",
"author": "",
"description": "",
"devDependencies": {
"@types/wechat-miniprogram": "^3.4.8",
"dts-bundle": "^0.7.3",
"ts-loader": "^9.5.1",
"typescript": "^5.6.3",
"webpack": "^5.96.1",
"webpack-cli": "^5.1.4"
}
}

31
node_modules/@jdmini/components/README.md generated vendored Normal file
View File

@ -0,0 +1,31 @@
## 安装/更新
1、终端使用命令安装 npm 包
```bash
npm install --save @jdmini/components@latest
```
2、在小程序开发者工具中菜单选择工具 -> 构建 npm
`注意:依赖@jdmini/api请确保小程序项目已安装@jdmini/api`
## 使用
1、在页面的 json 文件中引入组件:
```json
{
"usingComponents": {
"jdwx-ad": "@jdmini/components/jdwx-ad",
"jdwx-link": "@jdmini/components/jdwx-link"
}
}
```
2、在页面的 wxml 文件中使用组件:
```html
<jdwx-ad type="custom" />
<jdwx-link />
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,22 @@
import { adManager } from '@jdmini/api'
Component({
properties: {
type: {
type: String,
value: 'custom' // 可选banner, video, custom
}
},
data: {
ads: {}
},
lifetimes: {
attached: function () {
adManager.onDataReady(() => {
this.setData({ ads: adManager.ads })
})
},
},
methods: {
}
})

View File

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

View File

@ -0,0 +1,5 @@
<view class="jdwx-ad-component">
<ad wx:if="{{type === 'banner' && ads.banner}}" class="jdwx-ad-item" unit-id="{{ads.banner}}"></ad>
<ad wx:if="{{type === 'video' && ads.video}}" class="jdwx-ad-item" ad-type="video" unit-id="{{ads.video}}"></ad>
<ad-custom wx:if="{{type === 'custom' && ads.custom}}" class="jdwx-ad-item" unit-id="{{ads.custom}}"></ad-custom>
</view>

View File

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

View File

@ -0,0 +1,37 @@
import { adManager } from '@jdmini/api'
Component({
properties: {
},
data: {
link: [],
top: {},
},
pageLifetimes: {
show: function () {
adManager.onDataReady(() => {
this.setData({
link: adManager.link,
top: adManager.top
})
})
},
},
methods: {
gotopLink: function () {
wx.vibrateShort()
wx.openEmbeddedMiniProgram({
appId: this.data.top.appId,
path: this.data.top.linkPage
});
},
goLink: function (e) {
let index = e.currentTarget.id
wx.vibrateShort()
wx.openEmbeddedMiniProgram({
appId: this.data.link[index].appId,
path: this.data.link[index].linkPage
});
},
}
})

View File

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

View File

@ -0,0 +1,11 @@
<view class="jdwx-link-component">
<view wx:if="{{top.appId}}" class="jdwx-applink-top" bindtap="gotopLink">
<view><image src="{{top.appLogo}}" class="jdwx-applink-icon" /> </view>
<view ><text class="jdwx-applink-top-linkname">{{top.linkName}}</text>
<text class="jdwx-applink-top-text">{{top.appDsc}}</text> </view>
</view>
<view id="{{bindex}}" bindtap="goLink" wx:for="{{link}}" wx:for-index="bindex" wx:key="index" class="jdwx-applink-list">
<image src="{{item.appLogo}}" class="jdwx-applink-icon" />
<text class="jdwx-applink-text">{{item.linkName}}</text>
</view>
</view>

View File

@ -0,0 +1,63 @@
/* 页面容器 */
.jdwx-link-component {
/* background-image: linear-gradient(to right, #4F9863, #4F9863); */
background-attachment: fixed;
background-size: cover; /* 确保背景图像覆盖整个元素 */
/* height: 100vh; */
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding: 20rpx;
box-sizing: border-box;
overflow: auto; /* 允许内容滚动 */
}
/* 列表项样式 */
.jdwx-applink-list {
display: flex;
align-items: center;
width: 95%;
/* 假设我们想要每个view的高度约为屏幕高度的1/8 */
/* 使用小程序的wx.getSystemInfo API动态计算并设置这个值会更准确 */
height: calc((100vh - 40rpx) / 10); /* 减去容器padding的影响 */
padding: 20rpx;
background-color: rgba(248, 250, 252, 1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border-radius: 8rpx;
margin-bottom: 10rpx;
}
.jdwx-applink-top {
display: flex;
align-items: center;
width: 95%;
/* 假设我们想要每个view的高度约为屏幕高度的1/8 */
/* 使用小程序的wx.getSystemInfo API动态计算并设置这个值会更准确 */
height: calc((100vh - 40rpx) / 6); /* 减去容器padding的影响 */
padding: 20rpx;
border-radius: 8rpx;
margin-bottom: 30rpx;
background-color: rgba(248, 250, 252, 1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.jdwx-applink-top-linkname{
display: flex;
font-size: 36rpx;
color: rgb(39, 37, 37);
padding-bottom: 10rpx;
}
/* 图标样式 */
.jdwx-applink-icon {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
margin-right: 50rpx;
margin-left: 30rpx;
}
/* 文本样式 */
.jdwx-applink-text {
flex: 1;
font-size: 32rpx;
color: rgb(39, 37, 37);
}

20
node_modules/@jdmini/components/package.json generated vendored Normal file
View File

@ -0,0 +1,20 @@
{
"name": "@jdmini/components",
"version": "1.0.6",
"description": "",
"files": [
"miniprogram_dist",
"resources"
],
"scripts": {
"pub": "npm publish --access public"
},
"miniprogram": "miniprogram_dist",
"author": "",
"peerDependencies": {
"@jdmini/api": ">=1.0.8"
},
"devDependencies": {
"@types/wechat-miniprogram": "^3.4.8"
}
}

26
package-lock.json generated Normal file
View File

@ -0,0 +1,26 @@
{
"name": "template",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@jdmini/api": "^1.0.10",
"@jdmini/components": "^1.0.6"
}
},
"node_modules/@jdmini/api": {
"version": "1.0.10",
"resolved": "https://registry.npmmirror.com/@jdmini/api/-/api-1.0.10.tgz",
"integrity": "sha512-bVFU0awuY033mUT4QqArrYbrnPkBaBFKHoqCMHTVnRCk4b6gTs+cCGDH8uyf2t8ybCgWITKxaaH4Vjzyq8VF8g=="
},
"node_modules/@jdmini/components": {
"version": "1.0.6",
"resolved": "https://registry.npmmirror.com/@jdmini/components/-/components-1.0.6.tgz",
"integrity": "sha512-ndva1nlZ1QJqDVgHfB0GPxMGmXsZ7SbWjUkRm/WoQIkow75fFbaQCW/xhtQQ+bPbJLjXmCg2p2356klsLLib8A==",
"peerDependencies": {
"@jdmini/api": ">=1.0.8"
}
}
}
}

6
package.json Normal file
View File

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

View File

@ -0,0 +1,90 @@
import { injectPage } from '@jdmini/api'
const { getWorksheets } = require('../../utils/api.js')
const { DATA_BASE_URL } = require('../../utils/config.js')
Page(injectPage({})({
data: {
dataBaseUrl: DATA_BASE_URL,
categoryId: null,
categoryName: '',
worksheets: [],
page: 1,
pageSize: 20,
hasMore: true,
loading: false
},
onLoad(options) {
if (options.id) {
this.setData({ categoryId: Number(options.id) })
}
if (options.name) {
const name = decodeURIComponent(options.name)
this.setData({ categoryName: name })
wx.setNavigationBarTitle({ title: name })
}
this.loadWorksheets()
},
onPullDownRefresh() {
this.setData({
page: 1,
hasMore: true,
worksheets: []
})
this.loadWorksheets().finally(() => {
wx.stopPullDownRefresh()
})
},
onReachBottom() {
if (this.data.hasMore && !this.data.loading) {
this.loadMore()
}
},
// 加载练习表列表
async loadWorksheets() {
if (this.data.loading) return
try {
this.setData({ loading: true })
const res = await getWorksheets({
category_id: this.data.categoryId,
page: this.data.page,
pageSize: this.data.pageSize
})
if (res.success) {
const newWorksheets = res.data.list || []
this.setData({
worksheets: this.data.page === 1 ? newWorksheets : [...this.data.worksheets, ...newWorksheets],
hasMore: newWorksheets.length >= this.data.pageSize,
loading: false
})
}
} catch (error) {
console.error('加载练习表失败:', error)
this.setData({ loading: false })
}
},
// 加载更多
async loadMore() {
if (this.data.loading || !this.data.hasMore) return
this.setData({
page: this.data.page + 1
})
await this.loadWorksheets()
},
// 跳转详情页
goDetail(e) {
const id = e.currentTarget.dataset.id
wx.navigateTo({
url: `/pages/detail/detail?id=${id}`
})
}
}))

View File

@ -0,0 +1,5 @@
{
"usingComponents": {},
"enablePullDownRefresh": true,
"backgroundTextStyle": "dark"
}

View File

@ -0,0 +1,30 @@
<!-- 分类详情页面 -->
<view class="container">
<!-- 练习表网格 -->
<view class="worksheet-grid">
<view class="worksheet-item"
wx:for="{{worksheets}}"
wx:key="id"
bindtap="goDetail"
data-id="{{item.id}}">
<view class="worksheet-cover">
<image class="cover-img" src="{{dataBaseUrl}}{{item.coverurl}}" mode="aspectFill" lazy-load="{{true}}"></image>
</view>
<view class="worksheet-info">
<text class="worksheet-title text-ellipsis-2">{{item.title}}</text>
<text class="worksheet-e-title text-ellipsis">{{item.e_title}}</text>
</view>
</view>
</view>
<!-- 加载状态 -->
<view class="load-status" wx:if="{{worksheets.length > 0}}">
<text wx:if="{{loading}}">加载中...</text>
<text wx:elif="{{!hasMore}}">已加载全部</text>
</view>
<!-- 空状态 -->
<view class="empty-tip" wx:if="{{!loading && worksheets.length === 0}}">
<text>暂无练习表</text>
</view>
</view>

View File

@ -0,0 +1,77 @@
/* 分类详情页样式 */
.container {
padding: 20rpx;
min-height: 100vh;
background: #f5f5f5;
}
/* 练习表网格 */
.worksheet-grid {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
}
.worksheet-item {
width: calc(50% - 20rpx);
background: #fff;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.worksheet-cover {
position: relative;
width: 100%;
padding-top: 75%;
background: #f5f5f5;
}
.cover-img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.worksheet-info {
padding: 16rpx;
}
.worksheet-title {
display: block;
font-size: 26rpx;
color: #333;
line-height: 1.4;
height: 72rpx;
}
.worksheet-e-title {
display: block;
font-size: 22rpx;
color: #999;
margin-top: 8rpx;
}
/* 加载状态 */
.load-status {
text-align: center;
padding: 30rpx 0;
font-size: 26rpx;
color: #999;
}
/* 空状态 */
.empty-tip {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
}
.empty-tip text {
font-size: 28rpx;
color: #999;
}

131
pages/category/category.js Normal file
View File

@ -0,0 +1,131 @@
import { injectPage } from '@jdmini/api'
const { getCategories, getWorksheets } = require('../../utils/api.js')
const { DATA_BASE_URL } = require('../../utils/config.js')
Page(injectPage({})({
data: {
dataBaseUrl: DATA_BASE_URL,
categories: [],
currentCategory: 1,
currentCategoryName: '',
worksheets: [],
page: 1,
pageSize: 20,
total: 0,
hasMore: true,
loading: false
},
onLoad(options) {
// 如果传入了分类ID
if (options.id) {
this.setData({
currentCategory: Number(options.id)
})
}
this.loadCategories()
},
onPullDownRefresh() {
this.setData({
page: 1,
hasMore: true,
worksheets: []
})
this.loadWorksheets().finally(() => {
wx.stopPullDownRefresh()
})
},
// 加载分类列表
async loadCategories() {
try {
const res = await getCategories()
if (res.success && res.data.length > 0) {
const categories = res.data
// 如果没有设置当前分类,默认选中第一个
const currentCategory = this.data.currentCategory || categories[0].id
const currentCategoryObj = categories.find(c => c.id === currentCategory) || categories[0]
this.setData({
categories,
currentCategory: currentCategoryObj.id,
currentCategoryName: currentCategoryObj.name
})
// 加载该分类下的练习表
await this.loadWorksheets()
}
} catch (error) {
console.error('加载分类失败:', error)
wx.showToast({
title: '加载失败',
icon: 'none'
})
}
},
// 选择分类
async selectCategory(e) {
const categoryId = Number(e.currentTarget.dataset.id)
if (categoryId === this.data.currentCategory) return
const category = this.data.categories.find(c => c.id === categoryId)
this.setData({
currentCategory: categoryId,
currentCategoryName: category?.name || '',
page: 1,
hasMore: true,
worksheets: []
})
await this.loadWorksheets()
},
// 加载练习表列表
async loadWorksheets() {
if (this.data.loading) return
try {
this.setData({ loading: true })
const res = await getWorksheets({
category_id: this.data.currentCategory,
page: this.data.page,
pageSize: this.data.pageSize
})
if (res.success) {
const newWorksheets = res.data.list || []
this.setData({
worksheets: this.data.page === 1 ? newWorksheets : [...this.data.worksheets, ...newWorksheets],
total: res.data.pagination?.total || 0,
hasMore: newWorksheets.length >= this.data.pageSize,
loading: false
})
}
} catch (error) {
console.error('加载练习表失败:', error)
this.setData({ loading: false })
}
},
// 加载更多
async loadMore() {
if (this.data.loading || !this.data.hasMore) return
this.setData({
page: this.data.page + 1
})
await this.loadWorksheets()
},
// 跳转详情页
goDetail(e) {
const id = e.currentTarget.dataset.id
wx.navigateTo({
url: `/pages/detail/detail?id=${id}`
})
}
}))

View File

@ -0,0 +1,6 @@
{
"usingComponents": {},
"navigationBarTitleText": "分类",
"enablePullDownRefresh": true,
"backgroundTextStyle": "dark"
}

View File

@ -0,0 +1,56 @@
<!-- 分类页面 -->
<view class="container">
<!-- 左侧分类列表 -->
<scroll-view class="category-sidebar" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="category-item {{currentCategory === item.id ? 'active' : ''}}"
wx:for="{{categories}}"
wx:key="id"
bindtap="selectCategory"
data-id="{{item.id}}">
<view class="category-indicator" wx:if="{{currentCategory === item.id}}"></view>
<text class="category-name">{{item.name}}</text>
<text class="category-e-name">{{item.e_name}}</text>
</view>
</scroll-view>
<!-- 右侧内容区 -->
<scroll-view class="content-area"
scroll-y="{{true}}"
enhanced="{{true}}"
show-scrollbar="{{false}}"
bindscrolltolower="loadMore">
<!-- 分类标题 -->
<view class="content-header">
<text class="content-title">{{currentCategoryName}}</text>
<text class="content-count">共{{total}}个练习表</text>
</view>
<!-- 练习表网格 -->
<view class="worksheet-grid">
<view class="worksheet-item"
wx:for="{{worksheets}}"
wx:key="id"
bindtap="goDetail"
data-id="{{item.id}}">
<view class="worksheet-cover">
<image class="cover-img" src="{{dataBaseUrl}}{{item.coverurl}}" mode="aspectFill" lazy-load="{{true}}"></image>
</view>
<view class="worksheet-info">
<text class="worksheet-title text-ellipsis-2">{{item.title}}</text>
</view>
</view>
</view>
<!-- 加载状态 -->
<view class="load-status">
<text wx:if="{{loading}}">加载中...</text>
<text wx:elif="{{!hasMore && worksheets.length > 0}}">已加载全部</text>
</view>
<!-- 空状态 -->
<view class="empty-tip" wx:if="{{!loading && worksheets.length === 0}}">
<text>暂无练习表</text>
</view>
</scroll-view>
</view>

View File

@ -0,0 +1,155 @@
/* 分类页面样式 */
.container {
display: flex;
height: 100vh;
background: #f5f5f5;
}
/* 左侧分类列表 */
.category-sidebar {
width: 200rpx;
height: 100%;
background: #fff;
flex-shrink: 0;
}
.category-item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24rpx 16rpx;
background: #f8f8f8;
border-bottom: 1rpx solid #eee;
}
.category-item.active {
background: #fff;
}
.category-indicator {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 6rpx;
height: 50rpx;
background: linear-gradient(180deg, #4CAF50, #8BC34A);
border-radius: 0 6rpx 6rpx 0;
}
.category-name {
font-size: 26rpx;
color: #333;
text-align: center;
margin-bottom: 6rpx;
}
.category-item.active .category-name {
color: #4CAF50;
font-weight: bold;
}
.category-e-name {
font-size: 20rpx;
color: #999;
text-align: center;
}
/* 右侧内容区 */
.content-area {
flex: 1;
height: 100%;
padding: 20rpx;
}
.content-header {
display: flex;
align-items: baseline;
margin-bottom: 20rpx;
}
.content-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-right: 16rpx;
}
.content-count {
font-size: 24rpx;
color: #999;
}
/* 练习表网格 */
.worksheet-grid {
display: flex;
flex-wrap: wrap;
margin: 0 -8rpx;
}
.worksheet-item {
width: calc(50% - 16rpx);
margin: 0 8rpx 16rpx;
background: #fff;
border-radius: 12rpx;
overflow: hidden;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.worksheet-cover {
position: relative;
width: 100%;
padding-top: 75%;
background: #f5f5f5;
}
.cover-img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.worksheet-info {
padding: 12rpx;
}
.worksheet-title {
display: block;
font-size: 24rpx;
color: #333;
line-height: 1.4;
height: 67rpx;
}
/* 加载状态 */
.load-status {
text-align: center;
padding: 20rpx 0;
font-size: 24rpx;
color: #999;
}
/* 空状态 */
.empty-tip {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80rpx 0;
}
.empty-img {
width: 160rpx;
height: 160rpx;
margin-bottom: 16rpx;
opacity: 0.5;
}
.empty-tip text {
font-size: 26rpx;
color: #999;
}

388
pages/detail/detail.js Normal file
View File

@ -0,0 +1,388 @@
import { injectPage } from '@jdmini/api'
const { getWorksheetDetail, checkPoints, deductPoints } = require('../../utils/api.js')
const { DATA_BASE_URL } = require('../../utils/config.js')
Page(injectPage({})({
data: {
dataBaseUrl: DATA_BASE_URL,
id: null,
detail: {},
recommended: [],
loading: false,
downloadedPath: ''
},
onLoad(options) {
if (options.id) {
this.setData({ id: Number(options.id) })
this.loadDetail()
}
},
onShareAppMessage() {
const userId = wx.getStorageSync('userId')
let path = `/pages/detail/detail?id=${this.data.id}`
// 携带邀请人ID
if (userId) {
path += `&inviter=${userId}`
}
return {
title: this.data.detail.title || '儿童练习表',
path: path,
imageUrl: DATA_BASE_URL + this.data.detail.coverurl
}
},
// 加载详情
async loadDetail() {
try {
this.setData({ loading: true })
const res = await getWorksheetDetail(this.data.id)
if (res.success) {
// 设置导航栏标题
wx.setNavigationBarTitle({
title: res.data.detail.title || '练习表详情'
})
this.setData({
detail: res.data.detail || {},
recommended: res.data.recommended || [],
loading: false
})
}
} catch (error) {
console.error('加载详情失败:', error)
this.setData({ loading: false })
wx.showToast({
title: '加载失败',
icon: 'none'
})
}
},
// 预览封面大图
previewImage() {
const url = DATA_BASE_URL + this.data.detail.coverurl
wx.previewImage({
current: url,
urls: [url]
})
},
// 预览PDF
previewPdf() {
const pdfUrl = DATA_BASE_URL + this.data.detail.pdfurl
wx.showLoading({
title: '加载中...',
mask: true
})
// 先下载PDF文件
wx.downloadFile({
url: pdfUrl,
success: (res) => {
wx.hideLoading()
if (res.statusCode === 200) {
// 打开PDF文档
wx.openDocument({
filePath: res.tempFilePath,
fileType: 'pdf',
showMenu: true,
success: () => {
console.log('PDF打开成功')
},
fail: (err) => {
console.error('打开PDF失败:', err)
wx.showToast({
title: '打开失败',
icon: 'none'
})
}
})
}
},
fail: (err) => {
wx.hideLoading()
console.error('下载PDF失败:', err)
wx.showToast({
title: '加载失败',
icon: 'none'
})
}
})
},
// 检查是否已下载过
hasDownloaded(worksheetId) {
const downloads = wx.getStorageSync('downloads') || []
return downloads.some(d => d.id === worksheetId)
},
// 下载PDF
async downloadPdf() {
// 1. 检查是否登录
const userId = wx.getStorageSync('userId')
if (!userId) {
wx.showModal({
title: '提示',
content: '请先登录后再下载',
confirmText: '去登录',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
// 保存返回信息
wx.setStorageSync('returnTo', {
type: 'navigateTo',
url: `/pages/detail/detail?id=${this.data.id}`
})
wx.navigateTo({
url: '/pages/login/login'
})
}
}
})
return
}
// 2. 检查是否已下载过(已下载过的不扣积分)
const alreadyDownloaded = this.hasDownloaded(this.data.id)
// 3. 如果未下载过,检查积分
if (!alreadyDownloaded) {
try {
const pointsRes = await checkPoints(userId)
if (!pointsRes.success || !pointsRes.data.canDownload) {
wx.showModal({
title: '积分不足',
content: `下载需要 ${pointsRes.data?.costPoints || 1} 积分,您当前积分为 ${pointsRes.data?.points || 0}。分享小程序可获得积分!`,
confirmText: '去分享',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
// 触发分享
this.shareToGetPoints()
}
}
})
return
}
} catch (error) {
console.error('检查积分失败:', error)
wx.showToast({
title: '网络错误',
icon: 'none'
})
return
}
}
// 4. 开始下载
const pdfUrl = DATA_BASE_URL + this.data.detail.pdfurl
const title = this.data.detail.e_title || this.data.detail.title || 'worksheet'
const fileName = `${title}.pdf`
wx.showLoading({
title: '下载中...',
mask: true
})
try {
let remainingPoints = null
// 只有首次下载才扣积分
if (!alreadyDownloaded) {
const deductRes = await deductPoints(userId, this.data.id)
if (!deductRes.success) {
wx.hideLoading()
wx.showToast({
title: deductRes.message || '扣除积分失败',
icon: 'none'
})
return
}
remainingPoints = deductRes.data.remainingPoints
}
// 下载文件
wx.downloadFile({
url: pdfUrl,
success: (res) => {
wx.hideLoading()
if (res.statusCode === 200) {
const tempFilePath = res.tempFilePath
// 保存到本地
const fs = wx.getFileSystemManager()
const savedPath = `${wx.env.USER_DATA_PATH}/${fileName}`
fs.saveFile({
tempFilePath: tempFilePath,
filePath: savedPath,
success: (saveRes) => {
this.setData({ downloadedPath: saveRes.savedFilePath })
// 保存下载记录
this.saveDownloadRecord()
// 根据是否是首次下载显示不同提示
let content = '文件已保存,是否立即打开?'
if (!alreadyDownloaded && remainingPoints !== null) {
content = `文件已保存消耗1积分剩余${remainingPoints}积分。是否立即打开?`
} else if (alreadyDownloaded) {
content = '文件已保存(已购买,无需扣分)。是否立即打开?'
}
wx.showModal({
title: '下载成功',
content: content,
confirmText: '打开',
cancelText: '稍后',
success: (modalRes) => {
if (modalRes.confirm) {
wx.openDocument({
filePath: saveRes.savedFilePath,
fileType: 'pdf',
showMenu: true
})
}
}
})
},
fail: (err) => {
console.error('保存文件失败:', err)
// 如果保存失败,直接打开临时文件
wx.showModal({
title: '下载成功',
content: '是否立即打开?',
confirmText: '打开',
cancelText: '稍后',
success: (modalRes) => {
if (modalRes.confirm) {
wx.openDocument({
filePath: tempFilePath,
fileType: 'pdf',
showMenu: true
})
}
}
})
}
})
}
},
fail: (err) => {
wx.hideLoading()
console.error('下载失败:', err)
wx.showToast({
title: '下载失败',
icon: 'none'
})
}
})
} catch (error) {
wx.hideLoading()
console.error('下载失败:', error)
}
},
// 分享获取积分
shareToGetPoints() {
wx.showToast({
title: '点击右上角分享给好友',
icon: 'none',
duration: 2000
})
},
// 保存下载记录
saveDownloadRecord() {
try {
const downloads = wx.getStorageSync('downloads') || []
const record = {
id: this.data.id,
title: this.data.detail.title,
e_title: this.data.detail.e_title,
coverurl: this.data.detail.coverurl,
pdfurl: this.data.detail.pdfurl,
category_name: this.data.detail.category_name,
downloadTime: Date.now(),
filePath: this.data.downloadedPath
}
// 检查是否已存在
const existIndex = downloads.findIndex(d => d.id === record.id)
if (existIndex > -1) {
downloads[existIndex] = record
} else {
downloads.unshift(record)
}
// 最多保存100条
if (downloads.length > 100) {
downloads.pop()
}
wx.setStorageSync('downloads', downloads)
} catch (error) {
console.error('保存下载记录失败:', error)
}
},
// 打印PDF
printPdf() {
const pdfUrl = DATA_BASE_URL + this.data.detail.pdfurl
wx.showLoading({
title: '准备打印...',
mask: true
})
// 先下载PDF
wx.downloadFile({
url: pdfUrl,
success: (res) => {
wx.hideLoading()
if (res.statusCode === 200) {
// 微信小程序没有直接打印API引导用户通过打开文档后使用系统打印
wx.openDocument({
filePath: res.tempFilePath,
fileType: 'pdf',
showMenu: true,
success: () => {
wx.showToast({
title: '请点击右上角菜单进行打印',
icon: 'none',
duration: 3000
})
},
fail: (err) => {
console.error('打开PDF失败:', err)
wx.showToast({
title: '打开失败',
icon: 'none'
})
}
})
}
},
fail: (err) => {
wx.hideLoading()
console.error('下载失败:', err)
wx.showToast({
title: '加载失败',
icon: 'none'
})
}
})
},
// 跳转到其他详情
goDetail(e) {
const id = e.currentTarget.dataset.id
wx.redirectTo({
url: `/pages/detail/detail?id=${id}`
})
}
}))

4
pages/detail/detail.json Normal file
View File

@ -0,0 +1,4 @@
{
"usingComponents": {},
"navigationBarTitleText": "练习表详情"
}

59
pages/detail/detail.wxml Normal file
View File

@ -0,0 +1,59 @@
<!-- 详情页面 -->
<view class="container">
<!-- 封面预览 -->
<view class="preview-section">
<image class="preview-image"
src="{{dataBaseUrl}}{{detail.coverurl}}"
mode="aspectFit"
bindtap="previewImage"></image>
<view class="preview-tip">点击图片预览大图</view>
</view>
<!-- 基本信息 -->
<view class="info-section">
<view class="info-header">
<view class="category-tag">{{detail.category_name}}</view>
<view class="stats">
<text class="stat-item">浏览 {{detail.view_count || 0}}</text>
<text class="stat-item">下载 {{detail.download_count || 0}}</text>
</view>
</view>
<text class="info-title">{{detail.title}}</text>
<text class="info-e-title">{{detail.e_title}}</text>
<text class="info-desc" wx:if="{{detail.des}}">{{detail.des}}</text>
</view>
<view class="tips-box">
<text class="tips-icon">💡</text>
<text class="tips-text">Tips: 打开PDF点右上角'···'选择你打印机打印</text>
</view>
<!-- 操作按钮 -->
<view class="action-section">
<view class="action-btn download-btn" bindtap="downloadPdf">
<text>下载PDF</text>
</view>
</view>
<!-- 相关推荐 -->
<view class="recommend-section" wx:if="{{recommended.length > 0}}">
<view class="section-header">
<text class="section-title">相关推荐</text>
</view>
<scroll-view class="recommend-scroll" scroll-x="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="recommend-list">
<view class="recommend-item"
wx:for="{{recommended}}"
wx:key="id"
bindtap="goDetail"
data-id="{{item.id}}">
<image class="recommend-cover" src="{{dataBaseUrl}}{{item.coverurl}}" mode="aspectFill" lazy-load="{{true}}"></image>
<text class="recommend-title text-ellipsis">{{item.title}}</text>
</view>
</view>
</scroll-view>
</view>
</view>
<!-- 底部安全区域 -->
<view class="safe-bottom"></view>

213
pages/detail/detail.wxss Normal file
View File

@ -0,0 +1,213 @@
/* 详情页面样式 */
.container {
padding-bottom: 40rpx;
min-height: 100vh;
background: #f5f5f5;
}
/* 封面预览 */
.preview-section {
background: #fff;
padding: 30rpx;
text-align: center;
}
.preview-image {
width: 100%;
height: 500rpx;
border-radius: 16rpx;
background: #f5f5f5;
}
.preview-tip {
margin-top: 16rpx;
font-size: 24rpx;
color: #999;
}
/* 基本信息 */
.info-section {
background: #fff;
padding: 30rpx;
margin-top: 20rpx;
}
.info-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.category-tag {
display: inline-block;
padding: 8rpx 20rpx;
background: linear-gradient(135deg, #4CAF50, #8BC34A);
border-radius: 20rpx;
font-size: 24rpx;
color: #fff;
}
.stats {
display: flex;
align-items: center;
}
.stat-item {
font-size: 24rpx;
color: #999;
margin-left: 24rpx;
}
.info-title {
display: block;
font-size: 36rpx;
font-weight: bold;
color: #333;
line-height: 1.4;
margin-bottom: 12rpx;
}
.info-e-title {
display: block;
font-size: 28rpx;
color: #666;
margin-bottom: 20rpx;
}
.info-desc {
display: block;
font-size: 26rpx;
color: #999;
line-height: 1.6;
}
/* 操作按钮 */
.action-section {
display: flex;
justify-content: space-between;
padding: 30rpx;
background: #fff;
margin-top: 20rpx;
}
.action-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 24rpx 0;
margin: 0 10rpx;
border-radius: 16rpx;
transition: all 0.3s;
}
.action-btn:first-child {
margin-left: 0;
}
.action-btn:last-child {
margin-right: 0;
}
.preview-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.download-btn {
background: linear-gradient(135deg, #4CAF50, #8BC34A);
}
.print-btn {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.action-icon {
width: 48rpx;
height: 48rpx;
margin-bottom: 12rpx;
}
.action-btn text {
font-size: 26rpx;
color: #fff;
}
/* 相关推荐 */
.recommend-section {
background: #fff;
padding: 30rpx 0 30rpx 30rpx;
margin-top: 20rpx;
}
.section-header {
margin-bottom: 20rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.recommend-scroll {
width: 100%;
}
.recommend-list {
display: flex;
white-space: nowrap;
}
.recommend-item {
flex-shrink: 0;
width: 200rpx;
margin-right: 20rpx;
}
.recommend-item:last-child {
margin-right: 30rpx;
}
.recommend-cover {
width: 200rpx;
height: 150rpx;
border-radius: 12rpx;
background: #f5f5f5;
}
.recommend-title {
display: block;
margin-top: 12rpx;
font-size: 24rpx;
color: #333;
white-space: normal;
}
/* 提示信息 */
.tips-box {
display: flex;
align-items: center;
justify-content: center;
margin: 20rpx 30rpx;
padding: 16rpx 24rpx;
background: rgba(76, 175, 80, 0.1);
border-radius: 8rpx;
}
.tips-icon {
font-size: 28rpx;
margin-right: 8rpx;
}
.tips-text {
font-size: 28rpx;
color: #4CAF50;
line-height: 1.4;
}
/* 底部安全区域 */
.safe-bottom {
height: env(safe-area-inset-bottom);
}

150
pages/download/download.js Normal file
View File

@ -0,0 +1,150 @@
import { injectPage } from '@jdmini/api'
const { DATA_BASE_URL } = require('../../utils/config.js')
Page(injectPage({})({
data: {
dataBaseUrl: DATA_BASE_URL,
downloads: []
},
onShow() {
this.loadDownloads()
},
// 加载下载记录
loadDownloads() {
try {
const downloads = wx.getStorageSync('downloads') || []
// 格式化时间
const formattedDownloads = downloads.map(item => ({
...item,
downloadTimeStr: this.formatTime(item.downloadTime)
}))
this.setData({ downloads: formattedDownloads })
} catch (error) {
console.error('加载下载记录失败:', error)
}
},
// 格式化时间
formatTime(timestamp) {
const date = new Date(timestamp)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
},
// 打开文件
openFile(e) {
const index = e.currentTarget.dataset.index
const item = this.data.downloads[index]
if (item.filePath) {
// 尝试打开已保存的文件
wx.openDocument({
filePath: item.filePath,
fileType: 'pdf',
showMenu: true,
fail: (err) => {
console.error('打开文件失败:', err)
// 文件可能被删除,重新下载
this.redownload(item)
}
})
} else {
// 没有本地文件,重新下载
this.redownload(item)
}
},
// 重新下载
redownload(item) {
const pdfUrl = DATA_BASE_URL + item.pdfurl
wx.showLoading({
title: '加载中...',
mask: true
})
wx.downloadFile({
url: pdfUrl,
success: (res) => {
wx.hideLoading()
if (res.statusCode === 200) {
wx.openDocument({
filePath: res.tempFilePath,
fileType: 'pdf',
showMenu: true
})
}
},
fail: () => {
wx.hideLoading()
wx.showToast({
title: '加载失败',
icon: 'none'
})
}
})
},
// 删除下载记录
deleteItem(e) {
const index = e.currentTarget.dataset.index
const item = this.data.downloads[index]
wx.showModal({
title: '提示',
content: '确定删除此下载记录吗?',
success: (res) => {
if (res.confirm) {
// 删除本地文件
if (item.filePath) {
try {
const fs = wx.getFileSystemManager()
fs.unlinkSync(item.filePath)
} catch (error) {
console.log('删除文件失败或文件不存在')
}
}
// 更新记录
const downloads = this.data.downloads.filter((_, i) => i !== index)
this.setData({ downloads })
// 更新存储
const storageData = downloads.map(d => {
const { downloadTimeStr, ...rest } = d
return rest
})
wx.setStorageSync('downloads', storageData)
wx.showToast({
title: '已删除',
icon: 'success'
})
}
}
})
},
// 跳转详情
goDetail(e) {
const id = e.currentTarget.dataset.id
wx.navigateTo({
url: `/pages/detail/detail?id=${id}`
})
},
// 去浏览
goHome() {
wx.switchTab({
url: '/pages/index/index'
})
}
}))

View File

@ -0,0 +1,4 @@
{
"usingComponents": {},
"navigationBarTitleText": "我的下载"
}

View File

@ -0,0 +1,36 @@
<!-- 下载页面 -->
<view class="container">
<!-- 下载列表 -->
<view class="download-list" wx:if="{{downloads.length > 0}}">
<view class="download-item"
wx:for="{{downloads}}"
wx:key="id">
<view class="item-content" bindtap="goDetail" data-id="{{item.id}}">
<image class="item-cover" src="{{dataBaseUrl}}{{item.coverurl}}" mode="aspectFill"></image>
<view class="item-info">
<text class="item-title text-ellipsis-2">{{item.title}}</text>
<text class="item-category">{{item.category_name}}</text>
<text class="item-time">下载时间: {{item.downloadTimeStr}}</text>
</view>
</view>
<view class="item-actions">
<view class="action-btn" bindtap="openFile" data-index="{{index}}">
<text>打开</text>
</view>
<view class="action-btn delete" bindtap="deleteItem" data-index="{{index}}">
<text>删除</text>
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<view class="empty-state" wx:else>
<image class="empty-img" src="/images/icons/empty-download.png" mode="aspectFit"></image>
<text class="empty-title">暂无下载记录</text>
<text class="empty-desc">浏览练习表并下载PDF文件后将在此处显示</text>
<view class="empty-btn" bindtap="goHome">
<text>去浏览</text>
</view>
</view>
</view>

View File

@ -0,0 +1,129 @@
/* 下载页面样式 */
.container {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: env(safe-area-inset-bottom);
}
/* 下载列表 */
.download-list {
padding: 20rpx;
}
.download-item {
background: #fff;
border-radius: 16rpx;
margin-bottom: 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.item-content {
display: flex;
padding: 20rpx;
}
.item-cover {
width: 160rpx;
height: 120rpx;
border-radius: 8rpx;
background: #f5f5f5;
flex-shrink: 0;
}
.item-info {
flex: 1;
margin-left: 20rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.item-title {
font-size: 28rpx;
color: #333;
line-height: 1.4;
}
.item-category {
font-size: 24rpx;
color: #4CAF50;
}
.item-time {
font-size: 22rpx;
color: #999;
}
.item-actions {
display: flex;
border-top: 1rpx solid #f0f0f0;
}
.action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 20rpx 0;
border-right: 1rpx solid #f0f0f0;
}
.action-btn:last-child {
border-right: none;
}
.action-btn image {
width: 32rpx;
height: 32rpx;
margin-right: 8rpx;
}
.action-btn text {
font-size: 26rpx;
color: #666;
}
.action-btn.delete text {
color: #f56c6c;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 160rpx 60rpx;
}
.empty-img {
width: 240rpx;
height: 240rpx;
margin-bottom: 40rpx;
opacity: 0.6;
}
.empty-title {
font-size: 32rpx;
color: #333;
margin-bottom: 16rpx;
}
.empty-desc {
font-size: 26rpx;
color: #999;
text-align: center;
margin-bottom: 40rpx;
}
.empty-btn {
padding: 20rpx 60rpx;
background: linear-gradient(135deg, #4CAF50, #8BC34A);
border-radius: 40rpx;
}
.empty-btn text {
font-size: 28rpx;
color: #fff;
}

60
pages/index/index.js Normal file
View File

@ -0,0 +1,60 @@
import { injectPage } from '@jdmini/api'
const { getHomeData } = require('../../utils/api.js')
const { DATA_BASE_URL } = require('../../utils/config.js')
Page(injectPage({})({
data: {
dataBaseUrl: DATA_BASE_URL,
// 分类数据(每个分类的第一条作为封面)
worksheets: [],
loading: false
},
onLoad() {
this.loadHomeData()
},
onPullDownRefresh() {
this.loadHomeData().finally(() => {
wx.stopPullDownRefresh()
})
},
// 加载首页数据
async loadHomeData() {
try {
this.setData({ loading: true })
const res = await getHomeData()
if (res.success) {
this.setData({
worksheets: res.data.worksheets || [],
loading: false
})
}
} catch (error) {
console.error('加载首页数据失败:', error)
this.setData({ loading: false })
wx.showToast({
title: '加载失败',
icon: 'none'
})
}
},
// 跳转搜索页
goSearch() {
wx.navigateTo({
url: '/pages/search/search'
})
},
// 跳转到分类详情页面
goCategory(e) {
const categoryId = e.currentTarget.dataset.id
const item = this.data.worksheets.find(w => w.category_id === categoryId)
const categoryName = item ? item.category_name : ''
wx.navigateTo({
url: `/pages/category-detail/category-detail?id=${categoryId}&name=${encodeURIComponent(categoryName)}`
})
}
}))

5
pages/index/index.json Normal file
View File

@ -0,0 +1,5 @@
{
"usingComponents": {},
"enablePullDownRefresh": true,
"backgroundTextStyle": "dark"
}

48
pages/index/index.wxml Normal file
View File

@ -0,0 +1,48 @@
<!-- 首页 -->
<view class="container">
<!-- 搜索栏 -->
<view class="search-bar" bindtap="goSearch">
<view class="search-input">
<image class="search-icon" src="/images/icons/search.png" mode="aspectFit"></image>
<text class="search-placeholder">搜索练习表...</text>
</view>
</view>
<!-- Banner轮播图
<view class="banner-section">
<swiper class="banner-swiper"
indicator-dots="{{true}}"
autoplay="{{true}}"
interval="{{3000}}"
circular="{{true}}"
indicator-color="rgba(255,255,255,0.5)"
indicator-active-color="#ffffff">
<swiper-item wx:for="{{banners}}" wx:key="id">
<view class="banner-item" style="background: {{item.bgColor}}">
<view class="banner-content">
<text class="banner-title">{{item.title}}</text>
<text class="banner-desc">{{item.desc}}</text>
</view>
<image class="banner-img" src="{{item.image}}" mode="aspectFit"></image>
</view>
</swiper-item>
</swiper>
</view>
-->
<!-- 分类列表 -->
<view class="category-list-section">
<view class="category-card"
wx:for="{{worksheets}}"
wx:key="id"
bindtap="goCategory"
data-id="{{item.category_id}}">
<view class="category-cover">
<image class="cover-img" src="{{dataBaseUrl}}{{item.coverurl}}" mode="aspectFill" lazy-load="{{true}}"></image>
<view class="category-info">
<text class="category-name">{{item.category_name}}</text>
<text class="category-e-name">{{item.category_e_name}}</text>
</view>
</view>
</view>
</view>
</view>

130
pages/index/index.wxss Normal file
View File

@ -0,0 +1,130 @@
/* 首页样式 */
.container {
padding-bottom: 20rpx;
}
/* 搜索栏 */
.search-bar {
padding: 20rpx 30rpx;
background: #fff;
}
.search-input {
display: flex;
align-items: center;
padding: 16rpx 24rpx;
background: #f5f5f5;
border-radius: 40rpx;
}
.search-icon {
width: 36rpx;
height: 36rpx;
margin-right: 16rpx;
}
.search-placeholder {
color: #999;
font-size: 28rpx;
}
/* Banner轮播图 */
.banner-section {
padding: 0 30rpx 20rpx;
}
.banner-swiper {
height: 280rpx;
border-radius: 20rpx;
overflow: hidden;
}
.banner-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30rpx 40rpx;
height: 100%;
border-radius: 20rpx;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.banner-content {
flex: 1;
}
.banner-title {
display: block;
font-size: 40rpx;
font-weight: bold;
color: #fff;
margin-bottom: 16rpx;
}
.banner-desc {
display: block;
font-size: 26rpx;
color: rgba(255, 255, 255, 0.8);
}
.banner-img {
width: 180rpx;
height: 180rpx;
}
/* 分类列表 */
.category-list-section {
display: flex;
flex-wrap: wrap;
padding: 20rpx;
gap: 20rpx;
}
.category-card {
width: calc(50% - 20rpx);
background: #fff;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
}
.category-cover {
position: relative;
width: 100%;
padding-top: 100%;
background: #f5f5f5;
}
.category-cover .cover-img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.category-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 20rpx 16rpx;
background: rgba(76,175,80,0.85);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
text-align: center;
}
.category-name {
display: block;
font-size: 30rpx;
font-weight: bold;
color: #fff;
margin-bottom: 6rpx;
}
.category-e-name {
display: block;
font-size: 22rpx;
color: rgba(255,255,255,0.9);
}

220
pages/login/login.js Normal file
View File

@ -0,0 +1,220 @@
import { injectPage, gatewayHttpClient } from '@jdmini/api'
const api = require('../../utils/api.js')
const defaultAvatarUrl = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0'
Page(injectPage({})({
data: {
avatarUrl: defaultAvatarUrl,
nickname: '',
isLoading: false,
loginSuccess: false,
btnText: '完成登录',
btnIcon: '✓',
canSubmit: false
},
onLoad(options) {
// 检查是否已登录
const userId = wx.getStorageSync('userId');
if (userId) {
// 已登录,检查是否有返回页面
this.navigateBack();
return;
}
// 保存来源页面信息
if (options.from) {
this.setData({
fromPage: options.from
});
}
// 设置默认头像并检查提交状态
this.checkCanSubmit();
},
// 选择头像
onChooseAvatar(e) {
const { avatarUrl } = e.detail;
this.setData({
avatarUrl: avatarUrl
}, () => {
this.checkCanSubmit();
});
},
// 输入昵称
onNicknameInput(e) {
this.setData({
nickname: e.detail.value
}, () => {
this.checkCanSubmit();
});
},
// 检查是否可以提交
checkCanSubmit() {
const { avatarUrl, nickname } = this.data;
// 检查是否选择了自定义头像(非默认头像)和填写了昵称
const hasCustomAvatar = avatarUrl && avatarUrl !== defaultAvatarUrl;
const hasNickname = nickname && nickname.trim().length > 0;
const canSubmit = hasCustomAvatar && hasNickname;
this.setData({
canSubmit: canSubmit
});
},
// 处理登录
async handleLogin() {
const { avatarUrl, nickname, isLoading } = this.data;
if (isLoading) {
return;
}
// 检查头像是否已授权(是否选择了非默认头像)
const hasCustomAvatar = avatarUrl && avatarUrl !== defaultAvatarUrl;
// 检查昵称是否已填写
const hasNickname = nickname && nickname.trim().length > 0;
// 验证授权状态
if (!hasCustomAvatar && !hasNickname) {
wx.showToast({
title: '请先授权头像,再点授权昵称',
icon: 'none',
duration: 2500
});
return;
}
if (!hasCustomAvatar) {
wx.showToast({
title: '头像未授权',
icon: 'none',
duration: 2000
});
return;
}
if (!hasNickname) {
wx.showToast({
title: '昵称未授权',
icon: 'none',
duration: 2000
});
return;
}
// 开始登录
this.setData({
isLoading: true,
btnText: '登录中...',
btnIcon: '⏳'
});
// 获取 app.js 中通过 waitLogin 已经获取的第三方登录信息
const app = getApp();
const openid = app.globalData.openid;
const wxUserInfo = app.globalData.wxUserInfo || wx.getStorageSync('jdwx-userinfo');
if (!openid && !wxUserInfo) {
this.handleLoginError('获取登录信息失败,请重启小程序');
return;
}
//上传头像到图片服务
const JDavatarUrl= await gatewayHttpClient.uploadAvatar(avatarUrl)
console.log(JDavatarUrl.data)
// 使用 openid 进行登录
const finalOpenid = openid || wxUserInfo.openId;
this.performLogin(finalOpenid, nickname.trim(), JDavatarUrl.data);
},
// 执行登录请求
performLogin(openid, nickname, avatarUrl) {
// 获取邀请人ID
const inviterId = wx.getStorageSync('inviterId') || getApp().globalData.inviterId || null
// 调用后端登录接口
api.userLogin(openid, nickname, avatarUrl, inviterId).then(result => {
// 清除邀请人ID只在注册时使用一次
wx.removeStorageSync('inviterId')
// 保存用户信息
wx.setStorageSync('userId', result.data.user.id);
wx.setStorageSync('token', result.data.token);
wx.setStorageSync('userInfo', {
nickname: result.data.user.nickname,
avatar: result.data.user.avatar,
points: result.data.user.points
});
// 更新按钮状态
this.setData({
isLoading: false,
loginSuccess: true,
btnText: '登录成功',
btnIcon: '✓'
});
// 延迟跳转
setTimeout(() => {
this.navigateBack();
}, 1000);
}).catch(err => {
console.error('登录失败', err);
this.handleLoginError(err.message || '登录失败,请重试');
});
},
// 处理登录错误
handleLoginError(message) {
wx.showToast({
title: message,
icon: 'none'
});
this.setData({
isLoading: false,
btnText: '完成登录',
btnIcon: '✓'
});
},
// 导航返回
navigateBack() {
// 检查是否有存储的返回信息
const returnTo = wx.getStorageSync('returnTo');
if (returnTo) {
// 清除存储的返回信息
wx.removeStorageSync('returnTo');
// 根据返回类型进行跳转
if (returnTo.type === 'switchTab') {
wx.switchTab({
url: returnTo.url
});
} else if (returnTo.type === 'redirectTo') {
wx.redirectTo({
url: returnTo.url
});
} else {
wx.navigateTo({
url: returnTo.url
});
}
} else if (this.data.fromPage) {
// 使用URL参数中的来源页面
wx.navigateBack();
} else {
// 默认返回首页
wx.switchTab({
url: '/pages/index/index'
});
}
}
}))

4
pages/login/login.json Normal file
View File

@ -0,0 +1,4 @@
{
"navigationBarTitleText": "登录",
"usingComponents": {}
}

55
pages/login/login.wxml Normal file
View File

@ -0,0 +1,55 @@
<view class="container">
<!-- Logo和标题 -->
<view class="header">
<view class="logo">微</view>
<text class="title">微信登录授权</text>
<text class="subtitle">请授权获取您的头像和昵称信息</text>
</view>
<!-- 用户信息卡片 -->
<view class="user-card">
<button class="avatar-picker" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
<view class="avatar-wrapper {{avatarUrl ? 'has-avatar' : ''}}">
<image wx:if="{{avatarUrl}}" class="avatar-image" src="{{avatarUrl}}" mode="aspectFill"></image>
<text wx:else class="avatar-icon">👤</text>
</view>
</button>
<view class="nickname-wrapper">
<view class="nickname-btn-wrapper">
<input class="nickname-btn-input"
type="nickname"
placeholder="点击授权昵称"
value="{{nickname}}"
bindinput="onNicknameInput"
maxlength="20"
placeholder-class="nickname-placeholder" />
</view>
</view>
</view>
<!-- 授权按钮 -->
<view class="auth-btn {{canSubmit ? '' : 'disabled'}}"
bindtap="handleLogin">
<text wx:if="{{isLoading}}">授权中...</text>
<text wx:elif="{{loginSuccess}}">✓ 已授权</text>
<text wx:else>立即授权</text>
</view>
<!-- 授权说明 -->
<view class="permissions">
<view class="permissions-title">授权说明</view>
<view class="permission-item">
<text class="dot">•</text>
<text>获取您的公开信息(昵称、头像)</text>
</view>
<view class="permission-item">
<text class="dot">•</text>
<text>用于完善您的个人资料</text>
</view>
<view class="permission-item">
<text class="dot">•</text>
<text>我们承诺保护您的隐私安全</text>
</view>
</view>
</view>

218
pages/login/login.wxss Normal file
View File

@ -0,0 +1,218 @@
page {
background-color: #f5f5f5;
min-height: 100vh;
}
/* 主容器 */
.container {
padding: 0;
min-height: 100vh;
background-color: #f5f5f5;
display: flex;
flex-direction: column;
align-items: center;
}
/* 头部 */
.header {
text-align: center;
padding: 80rpx 0 60rpx;
background-color: #f5f5f5;
width: 100%;
}
.logo {
width: 160rpx;
height: 160rpx;
background: linear-gradient(135deg, #12b559 0%, #0aa750 100%);
border-radius: 32rpx;
margin: 0 auto 32rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 72rpx;
color: #fff;
box-shadow: 0 4rpx 16rpx rgba(18, 181, 89, 0.25);
}
.title {
display: block;
font-size: 34rpx;
font-weight: 400;
color: #000;
margin-bottom: 16rpx;
}
.subtitle {
display: block;
font-size: 26rpx;
color: #888;
line-height: 1.6;
}
/* 用户信息卡片 */
.user-card {
width: 620rpx;
background: #fff;
border-radius: 16rpx;
padding: 60rpx 0;
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 40rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
}
/* 头像选择器 */
.avatar-picker {
background: none;
border: none;
padding: 0;
margin: 0 auto 24rpx;
line-height: normal;
display: block;
width: 172rpx;
}
.avatar-picker::after {
border: none;
}
.avatar-wrapper {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 6rpx solid #07c160;
position: relative;
margin: 0 auto;
}
.avatar-wrapper.has-avatar {
border-color: #07c160;
}
.avatar-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-icon {
font-size: 80rpx;
color: #c8c8c8;
}
/* 昵称输入框(按钮样式) */
.nickname-wrapper {
width: 100%;
display: flex;
justify-content: center;
}
.nickname-btn-wrapper {
background: #f8f8f8;
border: 1rpx solid #e5e5e5;
border-radius: 8rpx;
padding: 20rpx 60rpx;
min-width: 400rpx;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.nickname-btn-wrapper:active {
background: #efefef;
}
.nickname-btn-input {
width: 100%;
height: 40rpx;
font-size: 32rpx;
color: #000;
font-weight: 400;
text-align: center;
background: transparent;
border: none;
}
.nickname-placeholder {
color: #999;
font-size: 32rpx;
text-align: center;
}
/* 授权按钮 */
.auth-btn {
width: 620rpx;
height: 88rpx;
background: #07c160;
border-radius: 8rpx;
border: none;
color: #fff;
font-size: 32rpx;
font-weight: 400;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 50rpx;
box-shadow: 0 2rpx 8rpx rgba(7, 193, 96, 0.3);
transition: all 0.3s;
cursor: pointer;
}
.auth-btn:active {
opacity: 0.8;
transform: scale(0.98);
}
.auth-btn.disabled {
background: #9ed99d;
color: rgba(255, 255, 255, 0.7);
box-shadow: none;
cursor: not-allowed;
}
.auth-btn.disabled:active {
opacity: 1;
transform: scale(1);
}
/* 授权说明 */
.permissions {
width: 620rpx;
padding: 0;
}
.permissions-title {
display: block;
font-size: 28rpx;
color: #000;
font-weight: 400;
margin-bottom: 24rpx;
}
.permission-item {
display: flex;
align-items: flex-start;
margin-bottom: 14rpx;
font-size: 26rpx;
color: #666;
line-height: 1.8;
}
.permission-item:last-child {
margin-bottom: 0;
}
.dot {
margin-right: 12rpx;
color: #000;
font-size: 24rpx;
line-height: 1.8;
}

231
pages/mine/mine.js Normal file
View File

@ -0,0 +1,231 @@
import { injectPage } from '@jdmini/api'
const { getUserInfo } = require('../../utils/api.js')
const defaultAvatar = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0'
Page(injectPage({})({
data: {
isLoggedIn: false,
userInfo: {},
defaultAvatar: defaultAvatar,
downloadCount: 0,
inviteCount: 0,
cacheSize: '0KB'
},
onShow() {
this.checkLoginStatus()
this.loadStats()
this.calculateCacheSize()
},
onShareAppMessage() {
const userId = wx.getStorageSync('userId')
let path = '/pages/index/index'
if (userId) {
path += `?inviter=${userId}`
}
return {
title: '儿童练习表 - 幼儿启蒙必备,快来一起学习吧!',
path: path
}
},
// 检查登录状态
checkLoginStatus() {
const userId = wx.getStorageSync('userId')
const userInfo = wx.getStorageSync('userInfo') || {}
if (userId) {
this.setData({
isLoggedIn: true,
userInfo: userInfo
})
// 刷新用户信息
this.refreshUserInfo(userId)
} else {
this.setData({
isLoggedIn: false,
userInfo: {}
})
}
},
// 刷新用户信息
async refreshUserInfo(userId) {
try {
const res = await getUserInfo(userId)
if (res.success) {
const userInfo = {
...this.data.userInfo,
nickname: res.data.nickname,
avatar: res.data.avatar,
points: res.data.points
}
this.setData({ userInfo })
wx.setStorageSync('userInfo', userInfo)
}
} catch (error) {
console.error('刷新用户信息失败:', error)
}
},
// 加载统计数据
loadStats() {
try {
const downloads = wx.getStorageSync('downloads') || []
// 邀请数暂时从本地存储获取,实际应该从服务端获取
const inviteCount = wx.getStorageSync('inviteCount') || 0
this.setData({
downloadCount: downloads.length,
inviteCount: inviteCount
})
} catch (error) {
console.error('加载统计失败:', error)
}
},
// 计算缓存大小
calculateCacheSize() {
try {
const res = wx.getStorageInfoSync()
const usedSize = res.currentSize // KB
let sizeStr = ''
if (usedSize < 1024) {
sizeStr = usedSize + 'KB'
} else {
sizeStr = (usedSize / 1024).toFixed(2) + 'MB'
}
this.setData({ cacheSize: sizeStr })
} catch (error) {
console.error('计算缓存大小失败:', error)
}
},
// 跳转登录
goLogin() {
wx.setStorageSync('returnTo', {
type: 'switchTab',
url: '/pages/mine/mine'
})
wx.navigateTo({
url: '/pages/login/login'
})
},
// 跳转下载页面
goDownloads() {
wx.switchTab({
url: '/pages/download/download'
})
},
// 跳转积分明细
goPointsLog() {
if (!this.data.isLoggedIn) {
this.goLogin()
return
}
wx.showToast({
title: '积分明细功能开发中',
icon: 'none'
})
},
// 清除缓存
clearCache() {
wx.showModal({
title: '提示',
content: '确定清除所有缓存吗?这将删除下载记录(不会退出登录)。',
success: (res) => {
if (res.confirm) {
try {
// 保存登录信息
const userId = wx.getStorageSync('userId')
const token = wx.getStorageSync('token')
const userInfo = wx.getStorageSync('userInfo')
// 清除下载的文件
const downloads = wx.getStorageSync('downloads') || []
const fs = wx.getFileSystemManager()
downloads.forEach(item => {
if (item.filePath) {
try {
fs.unlinkSync(item.filePath)
} catch (e) {
// 忽略文件不存在的错误
}
}
})
// 清除存储
wx.clearStorageSync()
// 恢复登录信息
if (userId) {
wx.setStorageSync('userId', userId)
wx.setStorageSync('token', token)
wx.setStorageSync('userInfo', userInfo)
}
this.setData({
downloadCount: 0,
inviteCount: 0,
cacheSize: '0KB'
})
wx.showToast({
title: '清除成功',
icon: 'success'
})
} catch (error) {
console.error('清除缓存失败:', error)
wx.showToast({
title: '清除失败',
icon: 'none'
})
}
}
}
})
},
// 退出登录
logout() {
wx.showModal({
title: '提示',
content: '确定退出登录吗?',
success: (res) => {
if (res.confirm) {
wx.removeStorageSync('userId')
wx.removeStorageSync('token')
wx.removeStorageSync('userInfo')
this.setData({
isLoggedIn: false,
userInfo: {}
})
wx.showToast({
title: '已退出登录',
icon: 'success'
})
}
}
})
},
// 关于我们
about() {
wx.showModal({
title: '关于儿童练习表',
content: '儿童练习表是一款专为幼儿设计的启蒙教育小程序,提供字母、数字、绘画、涂色等多种练习表,帮助孩子在快乐中学习成长。\n\n版本v1.0.0',
showCancel: false,
confirmText: '确定'
})
}
}))

4
pages/mine/mine.json Normal file
View File

@ -0,0 +1,4 @@
{
"usingComponents": {},
"navigationBarTitleText": "我的"
}

104
pages/mine/mine.wxml Normal file
View File

@ -0,0 +1,104 @@
<!-- 我的页面 -->
<view class="container">
<!-- 用户信息 - 已登录 -->
<view class="user-section" wx:if="{{isLoggedIn}}">
<view class="user-info">
<image class="user-avatar" src="{{userInfo.avatar || defaultAvatar}}" mode="aspectFill"></image>
<view class="user-detail">
<text class="user-name">{{userInfo.nickname || '微信用户'}}</text>
<text class="user-desc">陪伴孩子快乐学习</text>
</view>
</view>
<!-- 积分显示
<view class="points-box" bindtap="goPointsLog">
<text class="points-label">我的积分</text>
<text class="points-value">{{userInfo.points || 0}}</text>
<text class="points-arrow"></text>
</view>-->
</view>
<!-- 用户信息 - 未登录 -->
<view class="user-section user-section-unlogin" wx:else>
<view class="user-info" bindtap="goLogin">
<image class="user-avatar" src="{{defaultAvatar}}" mode="aspectFill"></image>
<view class="user-detail">
<text class="user-name">点击登录</text>
<text class="user-desc">登录后可下载练习表</text>
</view>
</view>
</view>
<!-- 统计信息 -->
<view class="stats-section">
<view class="stat-item">
<text class="stat-num">{{userInfo.points || 0}}</text>
<text class="stat-label">积分</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-num">{{downloadCount}}</text>
<text class="stat-label">下载数</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-num">{{inviteCount}}</text>
<text class="stat-label">邀请数</text>
</view>
</view>
<!-- 功能列表 -->
<view class="menu-section">
<view class="menu-item" bindtap="goDownloads">
<view class="menu-left">
<text class="menu-icon-emoji">📥</text>
<text class="menu-text">我的下载</text>
</view>
<text class="menu-arrow"></text>
</view>
<!--
<view class="menu-item" bindtap="goPointsLog">
<view class="menu-left">
<text class="menu-icon-emoji">💰</text>
<text class="menu-text">积分明细</text>
</view>
<text class="menu-arrow"></text>
</view>
-->
<button class="menu-btn" open-type="share">
<view class="menu-item">
<view class="menu-left">
<text class="menu-icon-emoji">🎁</text>
<text class="menu-text">邀请好友得积分</text>
</view>
<text class="menu-extra">+10积分/人</text>
</view>
</button>
<view class="menu-item" bindtap="clearCache">
<view class="menu-left">
<text class="menu-icon-emoji">🗑️</text>
<text class="menu-text">清除缓存</text>
</view>
<text class="menu-extra">{{cacheSize}}</text>
</view>
<view class="menu-item" bindtap="about">
<view class="menu-left">
<text class="menu-icon-emoji"></text>
<text class="menu-text">关于我们</text>
</view>
<text class="menu-extra">v1.0.0</text>
</view>
<!-- 退出登录 -->
<view class="menu-item" wx:if="{{isLoggedIn}}" bindtap="logout">
<view class="menu-left">
<text class="menu-icon-emoji">🚪</text>
<text class="menu-text">退出登录</text>
</view>
<text class="menu-arrow"></text>
</view>
</view>
<!-- 版权信息 -->
</view>

186
pages/mine/mine.wxss Normal file
View File

@ -0,0 +1,186 @@
/* 我的页面样式 */
.container {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: env(safe-area-inset-bottom);
}
/* 用户信息 */
.user-section {
background: linear-gradient(135deg, #4CAF50 0%, #8BC34A 100%);
padding: 60rpx 40rpx 80rpx;
}
.user-section-unlogin {
padding-bottom: 80rpx;
}
.user-info {
display: flex;
align-items: center;
}
.user-avatar {
width: 120rpx;
height: 120rpx;
border-radius: 60rpx;
border: 4rpx solid rgba(255, 255, 255, 0.3);
background: #fff;
}
.user-detail {
margin-left: 30rpx;
flex: 1;
}
.user-name {
display: block;
font-size: 36rpx;
font-weight: bold;
color: #fff;
margin-bottom: 8rpx;
}
.user-desc {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.8);
}
/* 积分显示 */
.points-box {
display: flex;
align-items: center;
margin-top: 30rpx;
padding: 20rpx 24rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 12rpx;
}
.points-label {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.9);
}
.points-value {
flex: 1;
text-align: right;
font-size: 36rpx;
font-weight: bold;
color: #fff;
margin-right: 10rpx;
}
.points-arrow {
font-size: 32rpx;
color: rgba(255, 255, 255, 0.7);
}
/* 统计信息 */
.stats-section {
display: flex;
align-items: center;
justify-content: space-around;
background: #fff;
margin: -40rpx 30rpx 0;
padding: 30rpx 0;
border-radius: 16rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.08);
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.stat-num {
font-size: 40rpx;
font-weight: bold;
color: #4CAF50;
}
.stat-label {
font-size: 24rpx;
color: #999;
margin-top: 8rpx;
}
.stat-divider {
width: 1rpx;
height: 60rpx;
background: #eee;
}
/* 功能列表 */
.menu-section {
background: #fff;
margin: 30rpx;
border-radius: 16rpx;
overflow: hidden;
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30rpx;
border-bottom: 1rpx solid #f5f5f5;
background: #fff;
}
.menu-item:last-child {
border-bottom: none;
}
/* 分享按钮样式重置 */
.menu-btn {
all: unset;
}
.menu-btn::after {
border: none;
}
.menu-left {
display: flex;
align-items: center;
}
.menu-icon {
width: 44rpx;
height: 44rpx;
margin-right: 20rpx;
}
.menu-icon-emoji {
font-size: 40rpx;
margin-right: 20rpx;
}
.menu-text {
font-size: 30rpx;
color: #333;
}
.menu-arrow {
font-size: 32rpx;
color: #ccc;
}
.menu-extra {
font-size: 26rpx;
color: #4CAF50;
}
/* 版权信息 */
.copyright {
text-align: center;
padding: 40rpx 0;
}
.copyright text {
font-size: 24rpx;
color: #ccc;
}

160
pages/search/search.js Normal file
View File

@ -0,0 +1,160 @@
import { injectPage } from '@jdmini/api'
const { searchWorksheets } = require('../../utils/api.js')
const { DATA_BASE_URL } = require('../../utils/config.js')
Page(injectPage({})({
data: {
dataBaseUrl: DATA_BASE_URL,
keyword: '',
history: [],
hotKeywords: ['字母', '数字', '涂色', '迷宫', '折纸', '形状'],
worksheets: [],
page: 1,
pageSize: 20,
total: 0,
hasMore: true,
loading: false,
hasSearched: false
},
onLoad() {
this.loadHistory()
},
// 加载搜索历史
loadHistory() {
try {
const history = wx.getStorageSync('searchHistory') || []
this.setData({ history })
} catch (error) {
console.error('加载搜索历史失败:', error)
}
},
// 保存搜索历史
saveHistory(keyword) {
try {
let history = wx.getStorageSync('searchHistory') || []
// 去重
history = history.filter(h => h !== keyword)
// 添加到开头
history.unshift(keyword)
// 最多保存10条
if (history.length > 10) {
history = history.slice(0, 10)
}
wx.setStorageSync('searchHistory', history)
this.setData({ history })
} catch (error) {
console.error('保存搜索历史失败:', error)
}
},
// 输入事件
onInput(e) {
this.setData({
keyword: e.detail.value
})
},
// 清除关键词
clearKeyword() {
this.setData({
keyword: '',
hasSearched: false,
worksheets: []
})
},
// 执行搜索
async doSearch() {
const keyword = this.data.keyword.trim()
if (!keyword) return
// 保存搜索历史
this.saveHistory(keyword)
this.setData({
page: 1,
hasMore: true,
worksheets: [],
hasSearched: true
})
await this.loadResults()
},
// 点击历史或热门关键词搜索
searchHistory(e) {
const keyword = e.currentTarget.dataset.keyword
this.setData({ keyword })
this.doSearch()
},
// 加载搜索结果
async loadResults() {
if (this.data.loading) return
try {
this.setData({ loading: true })
const res = await searchWorksheets({
keyword: this.data.keyword,
page: this.data.page,
pageSize: this.data.pageSize
})
if (res.success) {
const newWorksheets = res.data.list || []
this.setData({
worksheets: this.data.page === 1 ? newWorksheets : [...this.data.worksheets, ...newWorksheets],
total: res.data.pagination?.total || 0,
hasMore: newWorksheets.length >= this.data.pageSize,
loading: false
})
}
} catch (error) {
console.error('搜索失败:', error)
this.setData({ loading: false })
}
},
// 加载更多
async loadMore() {
if (this.data.loading || !this.data.hasMore) return
this.setData({
page: this.data.page + 1
})
await this.loadResults()
},
// 清除历史
clearHistory() {
wx.showModal({
title: '提示',
content: '确定清除搜索历史吗?',
success: (res) => {
if (res.confirm) {
wx.removeStorageSync('searchHistory')
this.setData({ history: [] })
}
}
})
},
// 返回
goBack() {
wx.navigateBack()
},
// 跳转详情
goDetail(e) {
const id = e.currentTarget.dataset.id
wx.navigateTo({
url: `/pages/detail/detail?id=${id}`
})
}
}))

4
pages/search/search.json Normal file
View File

@ -0,0 +1,4 @@
{
"usingComponents": {},
"navigationBarTitleText": "搜索"
}

92
pages/search/search.wxml Normal file
View File

@ -0,0 +1,92 @@
<!-- 搜索页面 -->
<view class="container">
<!-- 搜索栏 -->
<view class="search-bar">
<view class="search-input-wrap">
<image class="search-icon" src="/images/icons/search.png" mode="aspectFit"></image>
<input class="search-input"
type="text"
placeholder="搜索练习表..."
value="{{keyword}}"
focus="{{true}}"
confirm-type="search"
bindinput="onInput"
bindconfirm="doSearch" />
<image class="clear-icon"
wx:if="{{keyword}}"
src="/images/icons/clear.png"
mode="aspectFit"
bindtap="clearKeyword"></image>
</view>
<text class="cancel-btn" bindtap="goBack">取消</text>
</view>
<!-- 搜索历史 -->
<view class="history-section" wx:if="{{!hasSearched && history.length > 0}}">
<view class="section-header">
<text class="section-title">搜索历史</text>
<image class="delete-icon" src="/images/icons/delete.png" mode="aspectFit" bindtap="clearHistory"></image>
</view>
<view class="history-tags">
<view class="history-tag"
wx:for="{{history}}"
wx:key="index"
bindtap="searchHistory"
data-keyword="{{item}}">
{{item}}
</view>
</view>
</view>
<!-- 热门搜索 -->
<view class="hot-section" wx:if="{{!hasSearched}}">
<view class="section-header">
<text class="section-title">热门搜索</text>
</view>
<view class="hot-tags">
<view class="hot-tag"
wx:for="{{hotKeywords}}"
wx:key="index"
bindtap="searchHistory"
data-keyword="{{item}}">
{{item}}
</view>
</view>
</view>
<!-- 搜索结果 -->
<view class="result-section" wx:if="{{hasSearched}}">
<view class="result-header" wx:if="{{worksheets.length > 0}}">
<text>找到 {{total}} 个相关结果</text>
</view>
<view class="worksheet-list">
<view class="worksheet-item"
wx:for="{{worksheets}}"
wx:key="id"
bindtap="goDetail"
data-id="{{item.id}}">
<image class="item-cover" src="{{dataBaseUrl}}{{item.coverurl}}" mode="aspectFill"></image>
<view class="item-info">
<text class="item-title text-ellipsis-2">{{item.title}}</text>
<text class="item-e-title text-ellipsis">{{item.e_title}}</text>
<text class="item-category">{{item.category_name}}</text>
</view>
</view>
</view>
<!-- 加载状态 -->
<view class="load-status" wx:if="{{worksheets.length > 0}}">
<text wx:if="{{loading}}">加载中...</text>
<text wx:elif="{{!hasMore}}">已加载全部</text>
<text wx:else bindtap="loadMore">加载更多</text>
</view>
<!-- 无结果 -->
<view class="empty-result" wx:if="{{!loading && worksheets.length === 0}}">
<image class="empty-img" src="/images/icons/empty-search.png" mode="aspectFit"></image>
<text class="empty-text">未找到相关练习表</text>
<text class="empty-tip">换个关键词试试吧</text>
</view>
</view>
</view>

192
pages/search/search.wxss Normal file
View File

@ -0,0 +1,192 @@
/* 搜索页面样式 */
.container {
min-height: 100vh;
background: #f5f5f5;
}
/* 搜索栏 */
.search-bar {
display: flex;
align-items: center;
padding: 20rpx 30rpx;
background: #fff;
}
.search-input-wrap {
flex: 1;
display: flex;
align-items: center;
padding: 16rpx 24rpx;
background: #f5f5f5;
border-radius: 40rpx;
}
.search-icon {
width: 36rpx;
height: 36rpx;
margin-right: 16rpx;
opacity: 0.5;
}
.search-input {
flex: 1;
font-size: 28rpx;
color: #333;
}
.clear-icon {
width: 32rpx;
height: 32rpx;
margin-left: 16rpx;
opacity: 0.5;
}
.cancel-btn {
margin-left: 20rpx;
font-size: 28rpx;
color: #4CAF50;
}
/* 搜索历史 */
.history-section,
.hot-section {
background: #fff;
padding: 30rpx;
margin-top: 20rpx;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.section-title {
font-size: 30rpx;
font-weight: bold;
color: #333;
}
.delete-icon {
width: 36rpx;
height: 36rpx;
opacity: 0.5;
}
.history-tags,
.hot-tags {
display: flex;
flex-wrap: wrap;
}
.history-tag,
.hot-tag {
padding: 12rpx 24rpx;
margin: 0 16rpx 16rpx 0;
background: #f5f5f5;
border-radius: 30rpx;
font-size: 26rpx;
color: #666;
}
.hot-tag {
background: linear-gradient(135deg, rgba(76, 175, 80, 0.1), rgba(139, 195, 74, 0.1));
color: #4CAF50;
}
/* 搜索结果 */
.result-section {
padding: 20rpx 30rpx;
}
.result-header {
margin-bottom: 20rpx;
}
.result-header text {
font-size: 26rpx;
color: #999;
}
.worksheet-list {
background: #fff;
border-radius: 16rpx;
overflow: hidden;
}
.worksheet-item {
display: flex;
padding: 20rpx;
border-bottom: 1rpx solid #f5f5f5;
}
.worksheet-item:last-child {
border-bottom: none;
}
.item-cover {
width: 180rpx;
height: 135rpx;
border-radius: 12rpx;
background: #f5f5f5;
flex-shrink: 0;
}
.item-info {
flex: 1;
margin-left: 20rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.item-title {
font-size: 28rpx;
color: #333;
line-height: 1.4;
}
.item-e-title {
font-size: 24rpx;
color: #999;
}
.item-category {
font-size: 22rpx;
color: #4CAF50;
}
/* 加载状态 */
.load-status {
text-align: center;
padding: 30rpx 0;
font-size: 26rpx;
color: #999;
}
/* 无结果 */
.empty-result {
display: flex;
flex-direction: column;
align-items: center;
padding: 100rpx 0;
}
.empty-img {
width: 200rpx;
height: 200rpx;
margin-bottom: 30rpx;
opacity: 0.5;
}
.empty-text {
font-size: 30rpx;
color: #333;
margin-bottom: 12rpx;
}
.empty-tip {
font-size: 26rpx;
color: #999;
}

41
project.config.json Normal file
View File

@ -0,0 +1,41 @@
{
"compileType": "miniprogram",
"libVersion": "3.10.1",
"packOptions": {
"ignore": [],
"include": []
},
"setting": {
"coverView": true,
"es6": true,
"postcss": true,
"minified": true,
"enhance": true,
"showShadowRootInWxmlPanel": true,
"packNpmRelationList": [],
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
},
"compileWorklet": false,
"uglifyFileName": false,
"uploadWithSourceMap": true,
"packNpmManually": false,
"minifyWXSS": true,
"minifyWXML": true,
"localPlugins": false,
"condition": false,
"swc": false,
"disableSWC": true,
"disableUseStrict": false,
"useCompilerPlugins": false
},
"condition": {},
"editorSetting": {
"tabIndent": "auto",
"tabSize": 2
},
"appid": "wxe4ef1cc6e75de032",
"simulatorPluginLibVersion": {}
}

View File

@ -0,0 +1,24 @@
{
"description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html",
"projectname": "miniapp",
"setting": {
"compileHotReLoad": true,
"urlCheck": false,
"coverView": true,
"lazyloadPlaceholderEnable": false,
"skylineRenderEnable": false,
"preloadBackgroundData": false,
"autoAudits": false,
"useApiHook": true,
"useApiHostProcess": true,
"showShadowRootInWxmlPanel": true,
"useStaticServer": false,
"useLanDebug": false,
"showES6CompileOption": false,
"bigPackageSizeSupport": false,
"checkInvalidKey": true,
"ignoreDevUnusedFiles": true
},
"libVersion": "3.10.1",
"condition": {}
}

7
sitemap.json Normal file
View File

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

140
utils/api.js Normal file
View File

@ -0,0 +1,140 @@
/**
* API 接口定义
* 所有业务 API 接口的统一管理
*/
const { get, post } = require('./request.js')
/**
* ===============================
* 首页相关接口
* ===============================
*/
/**
* 获取首页数据分类 + 最新工作表
*/
function getHomeData() {
return get('/api/home')
}
/**
* 获取所有分类
*/
function getCategories() {
return get('/api/categories')
}
/**
* ===============================
* 工作表相关接口
* ===============================
*/
/**
* 获取工作表列表
* @param {Object} params
* @param {Number} params.category_id - 分类ID可选
* @param {Number} params.page - 页码
* @param {Number} params.pageSize - 每页数量
* @param {String} params.keyword - 搜索关键词
*/
function getWorksheets(params = {}) {
return get('/api/worksheets', params)
}
/**
* 获取工作表详情
* @param {Number} id - 工作表ID
*/
function getWorksheetDetail(id) {
return get(`/api/worksheets/${id}`)
}
/**
* 记录下载次数
* @param {Number} id - 工作表ID
*/
function recordDownload(id) {
return post(`/api/worksheets/${id}/download`)
}
/**
* 搜索工作表
* @param {Object} params
* @param {String} params.keyword - 搜索关键词
* @param {Number} params.page - 页码
* @param {Number} params.pageSize - 每页数量
*/
function searchWorksheets(params = {}) {
return get('/api/search', params)
}
/**
* ===============================
* 用户相关接口
* ===============================
*/
/**
* 用户登录/注册
* @param {String} openid - 微信 openid
* @param {String} nickname - 昵称
* @param {String} avatar - 头像URL
* @param {Number} inviter_id - 邀请人ID可选
*/
function userLogin(openid, nickname, avatar, inviter_id) {
return post('/api/user/login', { openid, nickname, avatar, inviter_id })
}
/**
* 获取用户信息
* @param {Number} user_id - 用户ID
*/
function getUserInfo(user_id) {
return get('/api/user/info', { user_id })
}
/**
* 检查积分是否足够下载
* @param {Number} user_id - 用户ID
*/
function checkPoints(user_id) {
return get('/api/user/check-points', { user_id })
}
/**
* 扣除积分下载时调用
* @param {Number} user_id - 用户ID
* @param {Number} worksheet_id - 工作表ID
*/
function deductPoints(user_id, worksheet_id) {
return post('/api/user/deduct-points', { user_id, worksheet_id })
}
/**
* 获取积分记录
* @param {Number} user_id - 用户ID
* @param {Number} page - 页码
* @param {Number} pageSize - 每页数量
*/
function getPointsLog(user_id, page = 1, pageSize = 20) {
return get('/api/user/points-log', { user_id, page, pageSize })
}
module.exports = {
// 首页
getHomeData,
getCategories,
// 工作表
getWorksheets,
getWorksheetDetail,
recordDownload,
searchWorksheets,
// 用户
userLogin,
getUserInfo,
checkPoints,
deductPoints,
getPointsLog
}

269
utils/auth.js Normal file
View File

@ -0,0 +1,269 @@
/**
* 用户认证工具模块
* 统一管理登录状态检查登录跳转等
*/
/**
* 检查用户是否已登录
* @returns {Boolean} 是否已登录
*/
function isLoggedIn() {
const userId = wx.getStorageSync('userId')
const token = wx.getStorageSync('token')
return !!(userId && token)
}
/**
* 获取用户信息
* @returns {Object|null} 用户信息对象或null
*/
function getUserInfo() {
if (!isLoggedIn()) {
return null
}
const userInfo = wx.getStorageSync('userInfo')
return userInfo || null
}
/**
* 获取用户ID
* @returns {String|null} 用户ID或null
*/
function getUserId() {
return wx.getStorageSync('userId') || null
}
/**
* 获取Token
* @returns {String|null} Token或null
*/
function getToken() {
return wx.getStorageSync('token') || null
}
/**
* 检查登录状态,未登录则跳转登录页
* @param {Object} options 配置项
* @param {String} options.returnUrl 登录成功后返回的页面URL
* @param {String} options.returnType 返回类型: navigateTo/redirectTo/switchTab
* @param {Function} options.success 已登录时的回调
* @param {Function} options.fail 未登录时的回调
* @returns {Boolean} 是否已登录
*/
function checkLogin(options = {}) {
const {
returnUrl = '',
returnType = 'navigateTo',
success,
fail
} = options
if (isLoggedIn()) {
// 已登录
success && success()
return true
} else {
// 未登录,跳转登录页
fail && fail()
// 保存返回信息
if (returnUrl) {
wx.setStorageSync('returnTo', {
url: returnUrl,
type: returnType
})
}
wx.navigateTo({
url: '/pages/login/login'
})
return false
}
}
/**
* 要求登录后执行操作
* @param {Function} callback 登录后执行的回调
* @param {Object} options 配置项
*/
function requireLogin(callback, options = {}) {
return checkLogin({
...options,
success: callback,
fail: () => {
wx.showToast({
title: '请先登录',
icon: 'none',
duration: 2000
})
}
})
}
/**
* 保存用户信息到本地
* @param {Object} data 用户数据
* @param {String} data.userId 用户ID
* @param {String} data.token Token
* @param {Object} data.userInfo 用户信息
*/
function saveUserData(data) {
const { userId, token, userInfo } = data
if (userId) {
wx.setStorageSync('userId', userId)
}
if (token) {
wx.setStorageSync('token', token)
}
if (userInfo) {
wx.setStorageSync('userInfo', userInfo)
}
}
/**
* 清除用户登录信息
*/
function clearUserData() {
wx.removeStorageSync('userId')
wx.removeStorageSync('token')
wx.removeStorageSync('userInfo')
}
/**
* 跳转到登录页面带返回功能
* 参考 checkin.js 的实现显示弹窗确认后跳转登录页
* @param {Object} options 配置项
* @param {String} options.message 提示信息
* @param {String} options.returnUrl 登录成功后返回的页面URL不传则自动获取当前页面
* @param {String} options.returnType 返回类型: navigateTo/redirectTo/switchTab
* @param {Boolean} options.showCancel 是否显示取消按钮
* @param {Function} options.onCancel 取消回调
*/
function navigateToLogin(options = {}) {
const {
message = '请先登录',
returnUrl = '',
returnType = 'navigateTo',
showCancel = true,
onCancel = null
} = options
// 自动获取当前页面路径
let finalReturnUrl = returnUrl
if (!finalReturnUrl) {
const pages = getCurrentPages()
if (pages.length > 0) {
const currentPage = pages[pages.length - 1]
finalReturnUrl = '/' + currentPage.route
// 添加页面参数
if (currentPage.options && Object.keys(currentPage.options).length > 0) {
const params = Object.keys(currentPage.options)
.map(key => `${key}=${currentPage.options[key]}`)
.join('&')
finalReturnUrl += '?' + params
}
}
}
wx.showModal({
title: '提示',
content: message,
confirmText: '去登录',
cancelText: '返回',
showCancel: showCancel,
success: (res) => {
if (res.confirm) {
// 保存返回信息,登录成功后返回当前页面
if (finalReturnUrl) {
wx.setStorageSync('returnTo', {
type: returnType,
url: finalReturnUrl
})
}
wx.redirectTo({
url: '/pages/login/login'
})
} else {
// 点击取消
if (onCancel) {
onCancel()
}
}
}
})
}
/**
* 退出登录
* @param {Object} options 配置项
* @param {String} options.redirectUrl 退出后跳转的页面
* @param {Boolean} options.confirm 是否需要确认
* @param {Function} options.success 成功回调
*/
function logout(options = {}) {
const {
redirectUrl = '/pages/home/home',
confirm = true,
success
} = options
const doLogout = () => {
clearUserData()
wx.showToast({
title: '已退出登录',
icon: 'success',
duration: 1500
})
setTimeout(() => {
success && success()
// 跳转到指定页面
if (redirectUrl.startsWith('/pages/home/') ||
redirectUrl.startsWith('/pages/index/') ||
redirectUrl.startsWith('/pages/record/') ||
redirectUrl.startsWith('/pages/settings/')) {
wx.reLaunch({
url: redirectUrl
})
} else {
wx.redirectTo({
url: redirectUrl
})
}
}, 1500)
}
if (confirm) {
wx.showModal({
title: '退出登录',
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
doLogout()
}
}
})
} else {
doLogout()
}
}
module.exports = {
isLoggedIn,
getUserInfo,
getUserId,
getToken,
checkLogin,
requireLogin,
navigateToLogin,
saveUserData,
clearUserData,
logout
}

40
utils/config.js Normal file
View File

@ -0,0 +1,40 @@
/**
* API 配置文件
* 统一管理开发环境和生产环境的配置
*/
// 开发模式开关
const IS_DEV = false
// 开发环境配置
const DEV_CONFIG = {
apiBase: 'http://localhost:3001',
timeout: 30000,
enableLog: true
}
// 生产环境配置
const PROD_CONFIG = {
apiBase: '/mp/jd-youerqimeng', // 幼儿启蒙模块
timeout: 30000,
enableLog: false
}
// 当前环境配置
const CONFIG = IS_DEV ? DEV_CONFIG : PROD_CONFIG
// 数据资源地址前缀
const DATA_BASE_URL = 'https://pic.miniappapi.com/youerqimeng'
module.exports = {
IS_DEV,
API_BASE: CONFIG.apiBase,
DATA_BASE_URL,
TIMEOUT: CONFIG.timeout,
ENABLE_LOG: CONFIG.enableLog,
// 切换环境方法(用于调试)
switchEnv: (isDev) => {
return isDev ? DEV_CONFIG : PROD_CONFIG
}
}

88
utils/httpClient.js Normal file
View File

@ -0,0 +1,88 @@
/**
* 统一的 HTTP 客户端
* 自动根据环境切换使用 gatewayHttpClient HttpClient
*/
import { gatewayHttpClient, HttpClient } from '@jdmini/api'
const { IS_DEV, API_BASE } = require('./config.js')
// 开发环境使用 HttpClient
const devHttpClient = IS_DEV ? new HttpClient({
baseURL: API_BASE,
timeout: 30000,
}) : null
/**
* 统一的 HTTP 请求方法
* @param {String} url - 请求路径相对路径不包含 baseURL
* @param {String} method - 请求方法 GET/POST/PUT/DELETE
* @param {Object} data - 请求数据
* @param {Object} options - 额外选项
* @returns {Promise}
*/
function request(url, method = 'GET', data = {}, options = {}) {
// 确保 URL 以 / 开头
const apiUrl = url.startsWith('/') ? url : `/${url}`
if (IS_DEV) {
// 开发环境:使用 HttpClient
console.log(`[DEV] ${method} ${API_BASE}${apiUrl}`, data)
return devHttpClient.request(apiUrl, method, data, options)
} else {
// 生产环境:使用 gatewayHttpClient
// gatewayHttpClient 需要完整的路径mp/jd-haiba/user/login
const fullUrl = `${API_BASE}${apiUrl}`
console.log(`[PROD] ${method} ${fullUrl}`, data)
// gatewayHttpClient.request 的参数顺序:(url, method, data, options)
return gatewayHttpClient.request(fullUrl, method, data, options)
}
}
/**
* GET 请求
*/
function get(url, params = {}, options = {}) {
return request(url, 'GET', params, options)
}
/**
* POST 请求
*/
function post(url, data = {}, options = {}) {
return request(url, 'POST', data, options)
}
/**
* PUT 请求
*/
function put(url, data = {}, options = {}) {
return request(url, 'PUT', data, options)
}
/**
* DELETE 请求
*/
function del(url, data = {}, options = {}) {
return request(url, 'DELETE', data, options)
}
/**
* 上传文件暂时保留原有逻辑
*/
function upload(filePath, formData = {}) {
return gatewayHttpClient.uploadFile(filePath,formData)
}
module.exports = {
request,
get,
post,
put,
del,
upload,
// 导出原始客户端供特殊场景使用
gatewayHttpClient,
devHttpClient
}

27
utils/index.js Normal file
View File

@ -0,0 +1,27 @@
/**
* Utils 统一导出
* 方便各个页面统一引入
*/
const config = require('./config.js')
const request = require('./request.js')
const api = require('./api.js')
const auth = require('./auth.js')
const httpClient = require('./httpClient.js')
module.exports = {
// 配置
...config,
// 请求方法
...request,
// API接口
...api,
// 认证
...auth,
// 统一 HTTP 客户端
httpClient
}

278
utils/request.js Normal file
View File

@ -0,0 +1,278 @@
/**
* 统一请求模块
* 封装所有 API 请求支持拦截器错误处理等
*/
const { API_BASE, TIMEOUT, ENABLE_LOG, IS_DEV } = require('./config.js')
const httpClient = require('./httpClient.js')
/**
* 统一请求方法
* @param {Object} options 请求配置
* @param {String} options.url 请求路径相对路径会自动拼接 API_BASE
* @param {String} options.method 请求方法 GET/POST/PUT/DELETE
* @param {Object} options.data 请求数据
* @param {Object} options.header 自定义请求头
* @param {Boolean} options.showLoading 是否显示加载提示
* @param {String} options.loadingText 加载提示文字
* @param {Boolean} options.showError 是否显示错误提示
* @returns {Promise}
*/
function request(options = {}) {
const {
url,
method = 'GET',
data = {},
header = {},
showLoading = false,
loadingText = '加载中...',
showError = true
} = options
// 显示加载提示
if (showLoading) {
wx.showLoading({
title: loadingText,
mask: true
})
}
// 拼接完整URL
const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`
// 构建请求头
const requestHeader = {
'Content-Type': 'application/json',
...header
}
// 添加 token如果存在
const token = wx.getStorageSync('token')
if (token) {
requestHeader['Authorization'] = `Bearer ${token}`
}
// 日志输出
if (ENABLE_LOG) {
console.log('[API Request]', {
url: fullUrl,
method,
data,
header: requestHeader
})
}
return new Promise((resolve, reject) => {
wx.request({
url: fullUrl,
method,
data,
header: requestHeader,
timeout: TIMEOUT,
success: (res) => {
// 隐藏加载提示
if (showLoading) {
wx.hideLoading()
}
// 日志输出
if (ENABLE_LOG) {
console.log('[API Response]', res.data)
}
// 统一处理响应
const { statusCode, data: resData } = res
// HTTP 状态码检查
if (statusCode >= 200 && statusCode < 300) {
// 业务状态码检查
if (resData.code === 200 || resData.success === true) {
resolve(resData)
} else {
// 业务错误
const errorMsg = resData.msg || resData.message || '请求失败'
if (showError) {
wx.showToast({
title: errorMsg,
icon: 'none',
duration: 2000
})
}
reject({
code: resData.code,
msg: errorMsg,
data: resData
})
}
} else {
// HTTP 错误
const errorMsg = `服务器错误 (${statusCode})`
if (showError) {
wx.showToast({
title: errorMsg,
icon: 'none',
duration: 2000
})
}
reject({
code: statusCode,
msg: errorMsg,
data: res
})
}
},
fail: (err) => {
// 隐藏加载提示
if (showLoading) {
wx.hideLoading()
}
// 日志输出
if (ENABLE_LOG) {
console.error('[API Error]', err)
}
// 网络错误
const errorMsg = err.errMsg || '网络请求失败'
if (showError) {
wx.showToast({
title: errorMsg,
icon: 'none',
duration: 2000
})
}
reject({
code: -1,
msg: errorMsg,
data: err
})
}
})
})
}
/**
* GET 请求
*/
function get(url, data = {}, options = {}) {
// 直接使用 httpClient它会自动处理环境切换
return httpClient.get(url, data, options)
}
/**
* POST 请求
*/
function post(url, data = {}, options = {}) {
// 直接使用 httpClient它会自动处理环境切换
return httpClient.post(url, data, options)
}
/**
* PUT 请求
*/
function put(url, data = {}, options = {}) {
// 直接使用 httpClient它会自动处理环境切换
return httpClient.put(url, data, options)
}
/**
* DELETE 请求
*/
function del(url, data = {}, options = {}) {
// 直接使用 httpClient它会自动处理环境切换
return httpClient.del(url, data, options)
}
/**
* 上传文件
* @param {String} url 上传地址
* @param {String} filePath 文件路径
* @param {Object} formData 额外的表单数据
* @param {Object} options 其他配置
*/
function uploadFile(filePath, formData = {}) {
const {
name = 'file',
showLoading = true,
loadingText = '上传中...',
showError = true
} = options
if (showLoading) {
wx.showLoading({
title: loadingText,
mask: true
})
}
const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`
// 构建请求头
const header = {}
const token = wx.getStorageSync('token')
if (token) {
header['Authorization'] = `Bearer ${token}`
}
return new Promise((resolve, reject) => {
wx.uploadFile({
url: fullUrl,
filePath,
name,
formData,
header,
timeout: TIMEOUT,
success: (res) => {
if (showLoading) {
wx.hideLoading()
}
const data = JSON.parse(res.data)
if (data.code === 200 || data.success === true) {
resolve(data)
} else {
const errorMsg = data.msg || data.message || '上传失败'
if (showError) {
wx.showToast({
title: errorMsg,
icon: 'none'
})
}
reject({
code: data.code,
msg: errorMsg,
data
})
}
},
fail: (err) => {
if (showLoading) {
wx.hideLoading()
}
const errorMsg = err.errMsg || '上传失败'
if (showError) {
wx.showToast({
title: errorMsg,
icon: 'none'
})
}
reject({
code: -1,
msg: errorMsg,
data: err
})
}
})
})
}
module.exports = {
request,
get,
post,
put,
del,
uploadFile
}