diff --git a/miniprogram/.gitignore b/miniprogram/.gitignore index 50bae59b..14ea590c 100644 --- a/miniprogram/.gitignore +++ b/miniprogram/.gitignore @@ -1,10 +1,14 @@ -# 小程序上传密钥(敏感信息,请勿上传) -private.key -private.*.key +# Windows +[Dd]esktop.ini +Thumbs.db +$RECYCLE.BIN/ -# 预览二维码 -preview.jpg - -# 微信开发者工具生成的文件 +# macOS .DS_Store +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes + +# Node.js node_modules/ diff --git a/miniprogram/app.js b/miniprogram/app.js index e0c3901b..8b507f28 100644 --- a/miniprogram/app.js +++ b/miniprogram/app.js @@ -10,6 +10,9 @@ App({ // 小程序配置 - 真实AppID appId: 'wxb8bbb2b10dec74aa', + + // 订阅消息:用户点击「申请提现」→「立即提现」时会先弹出订阅授权窗 + withdrawSubscribeTmplId: 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE', // 微信支付配置 mchId: '1318592501', // 商户号 @@ -27,6 +30,9 @@ App({ purchasedSections: [], hasFullBook: false, + // 已读章节(仅统计有权限打开过的章节,用于首页「已读/待读」) + readSectionIds: [], + // 推荐绑定 pendingReferralCode: null, // 待绑定的推荐码 @@ -49,6 +55,7 @@ App({ }, onLaunch(options) { + this.globalData.readSectionIds = wx.getStorageSync('readSectionIds') || [] // 获取系统信息 this.getSystemInfo() @@ -80,21 +87,16 @@ App({ // 立即记录访问(不需要登录,用于统计"通过链接进的人数") this.recordReferralVisit(refCode) + + // 保存待绑定的推荐码(不再在前端做"只能绑定一次"的限制,让后端根据30天规则判断续期/抢夺) + this.globalData.pendingReferralCode = refCode + wx.setStorageSync('pendingReferralCode', refCode) + // 同步写入 referral_code,供章节/找伙伴支付时传给后端,订单会记录 referrer_id 与 referral_code + wx.setStorageSync('referral_code', refCode) - // 检查是否已经绑定过 - const boundRef = wx.getStorageSync('boundReferralCode') - if (boundRef && boundRef !== refCode) { - console.log('[App] 已绑定过其他推荐码,不更换绑定关系') - // 但仍然记录访问,不return - } else { - // 保存待绑定的推荐码 - this.globalData.pendingReferralCode = refCode - wx.setStorageSync('pendingReferralCode', refCode) - - // 如果已登录,立即绑定 - if (this.globalData.isLoggedIn && this.globalData.userInfo) { - this.bindReferralCode(refCode) - } + // 如果已登录,立即尝试绑定,由 /api/miniprogram/referral/bind 按 30 天规则决定 new / renew / takeover + if (this.globalData.isLoggedIn && this.globalData.userInfo) { + this.bindReferralCode(refCode) } } }, @@ -106,7 +108,7 @@ App({ const openId = this.globalData.openId || wx.getStorageSync('openId') || '' const userId = this.globalData.userInfo?.id || '' - await this.request('/api/referral/visit', { + await this.request('/api/miniprogram/referral/visit', { method: 'POST', data: { referralCode: refCode, @@ -114,7 +116,8 @@ App({ visitorId: userId, source: 'miniprogram', page: getCurrentPages()[getCurrentPages().length - 1]?.route || '' - } + }, + silent: true }) console.log('[App] 记录推荐访问成功') } catch (e) { @@ -129,26 +132,21 @@ App({ const userId = this.globalData.userInfo?.id if (!userId || !refCode) return - // 检查是否已绑定 - const boundRef = wx.getStorageSync('boundReferralCode') - if (boundRef) { - console.log('[App] 已绑定推荐码,跳过') - return - } - console.log('[App] 绑定推荐码:', refCode, '到用户:', userId) // 调用API绑定推荐关系 - const res = await this.request('/api/referral/bind', { + const res = await this.request('/api/miniprogram/referral/bind', { method: 'POST', data: { userId, referralCode: refCode - } + }, + silent: true }) if (res.success) { console.log('[App] 推荐码绑定成功') + // 仅记录当前已绑定的推荐码,用于展示/调试;是否允许更换由后端根据30天规则判断 wx.setStorageSync('boundReferralCode', refCode) this.globalData.pendingReferralCode = null wx.removeStorageSync('pendingReferralCode') @@ -203,9 +201,10 @@ App({ // 从服务器获取最新数据 const res = await this.request('/api/book/all-chapters') - if (res && res.data) { - this.globalData.bookData = res.data - wx.setStorageSync('bookData', res.data) + if (res && (res.data || res.chapters)) { + const chapters = res.data || res.chapters || [] + this.globalData.bookData = chapters + wx.setStorageSync('bookData', chapters) } } catch (e) { console.error('加载书籍数据失败:', e) @@ -244,11 +243,41 @@ App({ } }, - // 统一请求方法 - request(url, options = {}) { + /** + * 从 soul-api 返回体中取错误提示文案(兼容 message / error 字段) + */ + _getApiErrorMsg(data, defaultMsg = '请求失败') { + if (!data || typeof data !== 'object') return defaultMsg + const msg = data.message || data.error + return (msg && String(msg).trim()) ? String(msg).trim() : defaultMsg + }, + + /** + * 统一请求方法。接口失败时会弹窗提示(与 soul-api 返回的 message/error 一致)。 + * @param {string|object} urlOrOptions - 接口路径,或 { url, method, data, header, silent } + * @param {object} options - { method, data, header, silent } + * @param {boolean} options.silent - 为 true 时不弹窗,仅 reject(用于静默请求如访问统计) + */ + request(urlOrOptions, options = {}) { + let url + if (typeof urlOrOptions === 'string') { + url = urlOrOptions + } else if (urlOrOptions && typeof urlOrOptions === 'object' && urlOrOptions.url) { + url = urlOrOptions.url + options = { ...urlOrOptions, url: undefined } + } else { + url = '' + } + const silent = !!options.silent + const showError = (msg) => { + if (!silent && msg) { + wx.showToast({ title: msg, icon: 'none', duration: 2500 }) + } + } + return new Promise((resolve, reject) => { const token = wx.getStorageSync('token') - + wx.request({ url: this.globalData.baseUrl + url, method: options.method || 'GET', @@ -259,38 +288,50 @@ App({ ...options.header }, success: (res) => { + const data = res.data if (res.statusCode === 200) { - resolve(res.data) - } else if (res.statusCode === 401) { - // 未授权,清除登录状态 - this.logout() - reject(new Error('未授权')) - } else { - reject(new Error(res.data?.message || '请求失败')) + // 业务失败:success === false,soul-api 用 message 或 error 返回原因 + if (data && data.success === false) { + const msg = this._getApiErrorMsg(data, '操作失败') + showError(msg) + reject(new Error(msg)) + return + } + resolve(data) + return } + if (res.statusCode === 401) { + this.logout() + showError('未授权,请重新登录') + reject(new Error('未授权')) + return + } + // 4xx/5xx:优先用返回体的 message/error + const msg = this._getApiErrorMsg(data, res.statusCode >= 500 ? '服务器异常,请稍后重试' : '请求失败') + showError(msg) + reject(new Error(msg)) }, fail: (err) => { - reject(err) + const msg = (err && err.errMsg) ? (err.errMsg.indexOf('timeout') !== -1 ? '请求超时,请重试' : '网络异常,请重试') : '网络异常,请重试' + showError(msg) + reject(new Error(msg)) } }) }) }, - // 登录方法 - 获取openId用于支付 + // 登录方法 - 获取openId用于支付(加固错误处理,避免审核报“登录报错”) async login() { try { - // 获取微信登录code const loginRes = await new Promise((resolve, reject) => { - wx.login({ - success: resolve, - fail: reject - }) + wx.login({ success: resolve, fail: reject }) }) - - console.log('[App] 获取登录code成功') - + if (!loginRes || !loginRes.code) { + console.warn('[App] wx.login 未返回 code') + wx.showToast({ title: '获取登录态失败,请重试', icon: 'none' }) + return null + } try { - // 发送code到服务器获取openId const res = await this.request('/api/miniprogram/login', { method: 'POST', data: { code: loginRes.code } @@ -362,6 +403,20 @@ App({ if (res.success && res.data?.openId) { this.globalData.openId = res.data.openId wx.setStorageSync('openId', res.data.openId) + // 接口同时返回 user 时视为登录,补全登录态并从登录开始绑定推荐码 + if (res.data.user) { + this.globalData.userInfo = res.data.user + this.globalData.isLoggedIn = true + this.globalData.purchasedSections = res.data.user.purchasedSections || [] + this.globalData.hasFullBook = res.data.user.hasFullBook || false + wx.setStorageSync('userInfo', res.data.user) + wx.setStorageSync('token', res.data.token || '') + const pendingRef = wx.getStorageSync('pendingReferralCode') || this.globalData.pendingReferralCode + if (pendingRef) { + console.log('[App] getOpenId 登录后自动绑定推荐码:', pendingRef) + this.bindReferralCode(pendingRef) + } + } return res.data.openId } } catch (e) { @@ -378,13 +433,19 @@ App({ return null }, - // 手机号登录 + // 手机号登录:需同时传 wx.login 的 code 与 getPhoneNumber 的 phoneCode async loginWithPhone(phoneCode) { try { - // 尝试API登录 - const res = await this.request('/api/wechat/phone-login', { + const loginRes = await new Promise((resolve, reject) => { + wx.login({ success: resolve, fail: reject }) + }) + if (!loginRes.code) { + wx.showToast({ title: '获取登录态失败', icon: 'none' }) + return null + } + const res = await this.request('/api/miniprogram/phone-login', { method: 'POST', - data: { code: phoneCode } + data: { code: loginRes.code, phoneCode } }) if (res.success && res.data) { @@ -430,6 +491,21 @@ App({ return this.globalData.purchasedSections.includes(sectionId) }, + // 标记章节为已读(仅在有权限打开时由阅读页调用,用于首页已读/待读统计) + markSectionAsRead(sectionId) { + if (!sectionId) return + const list = this.globalData.readSectionIds || [] + if (list.includes(sectionId)) return + list.push(sectionId) + this.globalData.readSectionIds = list + wx.setStorageSync('readSectionIds', list) + }, + + // 已读章节数(用于首页展示) + getReadCount() { + return (this.globalData.readSectionIds || []).length + }, + // 获取章节总数 getTotalSections() { return this.globalData.totalSections diff --git a/miniprogram/app.json b/miniprogram/app.json index bd4f1a8c..4bbe3721 100644 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -6,10 +6,15 @@ "pages/my/my", "pages/read/read", "pages/about/about", + "pages/agreement/agreement", + "pages/privacy/privacy", "pages/referral/referral", "pages/purchases/purchases", "pages/settings/settings", "pages/search/search", + "pages/addresses/addresses", + "pages/addresses/edit", + "pages/withdraw-records/withdraw-records", "pages/vip/vip", "pages/member-detail/member-detail" ], diff --git a/miniprogram/app.wxss b/miniprogram/app.wxss index 4a79d19f..9ce22a06 100644 --- a/miniprogram/app.wxss +++ b/miniprogram/app.wxss @@ -4,10 +4,48 @@ * 开发: 卡若 */ +/* ===== CSS 变量系统 ===== */ +page { + /* 品牌色 */ + --app-brand: #00CED1; + --app-brand-light: rgba(0, 206, 209, 0.1); + --app-brand-dark: #20B2AA; + + /* 背景色 */ + --app-bg-primary: #000000; + --app-bg-secondary: #1c1c1e; + --app-bg-tertiary: #2c2c2e; + + /* 文字色 */ + --app-text-primary: #ffffff; + --app-text-secondary: rgba(255, 255, 255, 0.7); + --app-text-tertiary: rgba(255, 255, 255, 0.4); + + /* 分隔线 */ + --app-separator: rgba(255, 255, 255, 0.05); + + /* iOS 系统色 */ + --ios-indigo: #5856D6; + --ios-green: #30d158; + --ios-red: #FF3B30; + --ios-orange: #FF9500; + --ios-yellow: #FFD700; + + /* 金色 */ + --gold: #FFD700; + --gold-light: #FFA500; + + /* 粉色 */ + --pink: #E91E63; + + /* 紫色 */ + --purple: #7B61FF; +} + /* ===== 页面基础样式 ===== */ page { - background-color: #000000; - color: #ffffff; + background-color: var(--app-bg-primary); + color: var(--app-text-primary); font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', 'PingFang SC', 'Microsoft YaHei', sans-serif; font-size: 28rpx; line-height: 1.5; diff --git a/miniprogram/assets/icons/alert-circle.svg b/miniprogram/assets/icons/alert-circle.svg new file mode 100644 index 00000000..f5a441f3 --- /dev/null +++ b/miniprogram/assets/icons/alert-circle.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/miniprogram/assets/icons/arrow-right.svg b/miniprogram/assets/icons/arrow-right.svg new file mode 100644 index 00000000..1dc64d3f --- /dev/null +++ b/miniprogram/assets/icons/arrow-right.svg @@ -0,0 +1,4 @@ + + + + diff --git a/miniprogram/assets/icons/bell.svg b/miniprogram/assets/icons/bell.svg new file mode 100644 index 00000000..0e7e405b --- /dev/null +++ b/miniprogram/assets/icons/bell.svg @@ -0,0 +1,4 @@ + + + + diff --git a/miniprogram/assets/icons/book-open.svg b/miniprogram/assets/icons/book-open.svg new file mode 100644 index 00000000..d833e86b --- /dev/null +++ b/miniprogram/assets/icons/book-open.svg @@ -0,0 +1,4 @@ + + + + diff --git a/miniprogram/assets/icons/book.svg b/miniprogram/assets/icons/book.svg new file mode 100644 index 00000000..93579576 --- /dev/null +++ b/miniprogram/assets/icons/book.svg @@ -0,0 +1,4 @@ + + + + diff --git a/miniprogram/assets/icons/chevron-left.svg b/miniprogram/assets/icons/chevron-left.svg new file mode 100644 index 00000000..e406b2b9 --- /dev/null +++ b/miniprogram/assets/icons/chevron-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/miniprogram/assets/icons/gift.svg b/miniprogram/assets/icons/gift.svg new file mode 100644 index 00000000..66ac806c --- /dev/null +++ b/miniprogram/assets/icons/gift.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/miniprogram/assets/icons/home.svg b/miniprogram/assets/icons/home.svg new file mode 100644 index 00000000..76244091 --- /dev/null +++ b/miniprogram/assets/icons/home.svg @@ -0,0 +1,4 @@ + + + + diff --git a/miniprogram/assets/icons/image.svg b/miniprogram/assets/icons/image.svg new file mode 100644 index 00000000..50ed9e6d --- /dev/null +++ b/miniprogram/assets/icons/image.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/miniprogram/assets/icons/list.svg b/miniprogram/assets/icons/list.svg new file mode 100644 index 00000000..688326aa --- /dev/null +++ b/miniprogram/assets/icons/list.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/miniprogram/assets/icons/message-circle.svg b/miniprogram/assets/icons/message-circle.svg new file mode 100644 index 00000000..037560e9 --- /dev/null +++ b/miniprogram/assets/icons/message-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/miniprogram/assets/icons/partners.svg b/miniprogram/assets/icons/partners.svg new file mode 100644 index 00000000..80668312 --- /dev/null +++ b/miniprogram/assets/icons/partners.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + diff --git a/miniprogram/assets/icons/settings.svg b/miniprogram/assets/icons/settings.svg new file mode 100644 index 00000000..c7006ea8 --- /dev/null +++ b/miniprogram/assets/icons/settings.svg @@ -0,0 +1,4 @@ + + + + diff --git a/miniprogram/assets/icons/share.svg b/miniprogram/assets/icons/share.svg new file mode 100644 index 00000000..93179fc2 --- /dev/null +++ b/miniprogram/assets/icons/share.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/miniprogram/assets/icons/sparkles.svg b/miniprogram/assets/icons/sparkles.svg new file mode 100644 index 00000000..e2a4461f --- /dev/null +++ b/miniprogram/assets/icons/sparkles.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/miniprogram/assets/icons/user.svg b/miniprogram/assets/icons/user.svg new file mode 100644 index 00000000..8b190427 --- /dev/null +++ b/miniprogram/assets/icons/user.svg @@ -0,0 +1,4 @@ + + + + diff --git a/miniprogram/assets/icons/users.svg b/miniprogram/assets/icons/users.svg new file mode 100644 index 00000000..4816094b --- /dev/null +++ b/miniprogram/assets/icons/users.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/miniprogram/assets/icons/wallet.svg b/miniprogram/assets/icons/wallet.svg new file mode 100644 index 00000000..6d431e54 --- /dev/null +++ b/miniprogram/assets/icons/wallet.svg @@ -0,0 +1,4 @@ + + + + diff --git a/miniprogram/components/icon/README.md b/miniprogram/components/icon/README.md new file mode 100644 index 00000000..34e394c8 --- /dev/null +++ b/miniprogram/components/icon/README.md @@ -0,0 +1,175 @@ +# Icon 图标组件 + +SVG 图标组件,参考 lucide-react 实现,用于在小程序中使用矢量图标。 + +**技术实现**: 使用 Base64 编码的 SVG + image 组件(小程序不支持直接使用 SVG 标签) + +--- + +## 使用方法 + +### 1. 在页面 JSON 中引入组件 + +```json +{ + "usingComponents": { + "icon": "/components/icon/icon" + } +} +``` + +### 2. 在 WXML 中使用 + +```xml + + + + + + + + + + + + + + + + + +``` + +--- + +## 属性说明 + +| 属性 | 类型 | 默认值 | 说明 | +|-----|------|--------|-----| +| name | String | 'share' | 图标名称 | +| size | Number | 48 | 图标大小(rpx) | +| color | String | 'currentColor' | 图标颜色 | +| customClass | String | '' | 自定义类名 | +| customStyle | String | '' | 自定义样式 | + +--- + +## 可用图标 + +| 图标名称 | 说明 | 对应 lucide-react | +|---------|------|-------------------| +| `share` | 分享 | `` | +| `arrow-up-right` | 右上箭头 | `` | +| `chevron-left` | 左箭头 | `` | +| `search` | 搜索 | `` | +| `heart` | 心形 | `` | + +--- + +## 添加新图标 + +在 `icon.js` 的 `getSvgPath` 方法中添加新图标: + +```javascript +getSvgPath(name) { + const svgMap = { + 'new-icon': '', + // ... 其他图标 + } + return svgMap[name] || '' +} +``` + +**获取 SVG 代码**: 访问 [lucide.dev](https://lucide.dev) 搜索图标,复制 SVG 内容。 +**注意**: 颜色使用 `COLOR` 占位符,组件会自动替换。 + +--- + +## 样式定制 + +### 1. 使用 customClass + +```xml + +``` + +```css +.my-icon-class { + opacity: 0.8; +} +``` + +### 2. 使用 customStyle + +```xml + +``` + +--- + +## 技术说明 + +### 为什么使用 Base64 + image? + +1. **矢量图标**:任意缩放不失真 +2. **灵活着色**:通过 `COLOR` 占位符动态改变颜色 +3. **轻量级**:无需加载字体文件或外部图片 +4. **兼容性**:小程序不支持直接使用 SVG 标签,image 组件支持 Base64 SVG + +### 为什么不用字体图标? + +小程序对字体文件有限制,Base64 编码字体文件会增加包体积,SVG 图标更轻量。 + +### 与 lucide-react 的对应关系 + +- **lucide-react**: React 组件库,使用 SVG +- **本组件**: 小程序自定义组件,也使用 SVG +- **SVG path 数据**: 完全相同,从 lucide 官网复制 + +--- + +## 示例 + +### 悬浮分享按钮 + +```xml + +``` + +```css +.fab-share { + position: fixed; + right: 32rpx; + bottom: calc(120rpx + env(safe-area-inset-bottom)); + width: 96rpx; + height: 96rpx; + border-radius: 50%; + background: linear-gradient(135deg, #00CED1 0%, #20B2AA 100%); + display: flex; + align-items: center; + justify-content: center; +} +``` + +--- + +## 扩展图标库 + +可以继续添加更多 lucide-react 图标: + +- `star` - 星星 +- `wallet` - 钱包 +- `gift` - 礼物 +- `info` - 信息 +- `settings` - 设置 +- `user` - 用户 +- `book-open` - 打开的书 +- `eye` - 眼睛 +- `clock` - 时钟 +- `users` - 用户组 + +--- + +**图标组件创建完成!** 🎉 diff --git a/miniprogram/components/icon/icon.js b/miniprogram/components/icon/icon.js new file mode 100644 index 00000000..b2dec23f --- /dev/null +++ b/miniprogram/components/icon/icon.js @@ -0,0 +1,83 @@ +// components/icon/icon.js +Component({ + properties: { + // 图标名称 + name: { + type: String, + value: 'share', + observer: 'updateIcon' + }, + // 图标大小(rpx) + size: { + type: Number, + value: 48 + }, + // 图标颜色 + color: { + type: String, + value: '#ffffff', + observer: 'updateIcon' + }, + // 自定义类名 + customClass: { + type: String, + value: '' + }, + // 自定义样式 + customStyle: { + type: String, + value: '' + } + }, + + data: { + svgData: '' + }, + + lifetimes: { + attached() { + this.updateIcon() + } + }, + + methods: { + // SVG 图标数据映射 + getSvgPath(name) { + const svgMap = { + 'share': '', + + 'arrow-up-right': '', + + 'chevron-left': '', + + 'search': '', + + 'heart': '' + } + + return svgMap[name] || '' + }, + + // 更新图标 + updateIcon() { + const { name, color } = this.data + let svgString = this.getSvgPath(name) + + if (svgString) { + // 替换颜色占位符 + svgString = svgString.replace(/COLOR/g, color) + + // 转换为 Base64 Data URL + const svgData = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgString)}` + + this.setData({ + svgData: svgData + }) + } else { + this.setData({ + svgData: '' + }) + } + } + } +}) diff --git a/miniprogram/components/icon/icon.json b/miniprogram/components/icon/icon.json new file mode 100644 index 00000000..a89ef4db --- /dev/null +++ b/miniprogram/components/icon/icon.json @@ -0,0 +1,4 @@ +{ + "component": true, + "usingComponents": {} +} diff --git a/miniprogram/components/icon/icon.wxml b/miniprogram/components/icon/icon.wxml new file mode 100644 index 00000000..b1c29a25 --- /dev/null +++ b/miniprogram/components/icon/icon.wxml @@ -0,0 +1,5 @@ + + + + {{name}} + diff --git a/miniprogram/components/icon/icon.wxss b/miniprogram/components/icon/icon.wxss new file mode 100644 index 00000000..d12d2a0a --- /dev/null +++ b/miniprogram/components/icon/icon.wxss @@ -0,0 +1,18 @@ +/* components/icon/icon.wxss */ +.icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.icon-image { + display: block; + width: 100%; + height: 100%; +} + +.icon-text { + font-size: 24rpx; + color: currentColor; +} diff --git a/miniprogram/custom-tab-bar/index.js b/miniprogram/custom-tab-bar/index.js index 7cd2fbe2..4acd9546 100644 --- a/miniprogram/custom-tab-bar/index.js +++ b/miniprogram/custom-tab-bar/index.js @@ -1,13 +1,19 @@ /** * Soul创业实验 - 自定义TabBar组件 - * 实现中间突出的"找伙伴"按钮 + * 根据后台配置动态显示/隐藏"找伙伴"按钮 */ +console.log('[TabBar] ===== 组件文件开始加载 =====') + +const app = getApp() +console.log('[TabBar] App 对象:', app) + Component({ data: { selected: 0, color: '#8e8e93', selectedColor: '#00CED1', + matchEnabled: false, // 找伙伴功能开关,默认关闭 list: [ { pagePath: '/pages/index/index', @@ -23,20 +29,34 @@ Component({ pagePath: '/pages/match/match', text: '找伙伴', iconType: 'match', - isSpecial: true, - hidden: true // 默认隐藏,等配置加载后根据后台设置显示 + isSpecial: true }, { pagePath: '/pages/my/my', text: '我的', iconType: 'user' } - ], - matchEnabled: false // 找伙伴功能开关(默认隐藏,等待后台配置加载) + ] }, + lifetimes: { + attached() { + console.log('[TabBar] Component attached 生命周期触发') + this.loadFeatureConfig() + }, + ready() { + console.log('[TabBar] Component ready 生命周期触发') + // 如果 attached 中没有成功加载,在 ready 中再次尝试 + if (this.data.matchEnabled === undefined || this.data.matchEnabled === null) { + console.log('[TabBar] 在 ready 中重新加载配置') + this.loadFeatureConfig() + } + } + }, + + // 页面加载时也调用(兼容性更好) attached() { - // 初始化时获取当前页面 + console.log('[TabBar] attached() 方法触发') this.loadFeatureConfig() }, @@ -44,31 +64,82 @@ Component({ // 加载功能配置 async loadFeatureConfig() { try { - const app = getApp() - const res = await app.request('/api/db/config') + console.log('[TabBar] 开始加载功能配置...') + console.log('[TabBar] API地址:', app.globalData.baseUrl + '/api/miniprogram/config') - if (res.success && res.features) { - const matchEnabled = res.features.matchEnabled === true - this.setData({ matchEnabled }) - - // 更新list,隐藏或显示找伙伴 - const list = this.data.list.map(item => { - if (item.iconType === 'match') { - return { ...item, hidden: !matchEnabled } - } - return item - }) - this.setData({ list }) - - console.log('[TabBar] 功能配置加载成功,找伙伴功能:', matchEnabled ? '开启' : '关闭') + // app.request 的第一个参数是 url 字符串,第二个参数是 options 对象 + const res = await app.request('/api/miniprogram/config', { + method: 'GET' + }) + + + // 兼容两种返回格式 + let matchEnabled = false + + if (res && res.success && res.features) { + console.log('[TabBar] features配置:', JSON.stringify(res.features)) + matchEnabled = res.features.matchEnabled === true + console.log('[TabBar] matchEnabled值:', matchEnabled) + } else if (res && res.configs && res.configs.feature_config) { + // 备用格式:从 configs.feature_config 读取 + console.log('[TabBar] 使用备用格式,从configs读取') + matchEnabled = res.configs.feature_config.matchEnabled === true + console.log('[TabBar] matchEnabled值:', matchEnabled) + } else { + console.log('[TabBar] ⚠️ 未找到features配置,使用默认值false') + console.log('[TabBar] res对象keys:', Object.keys(res || {})) } - } catch (e) { - console.log('[TabBar] 加载功能配置失败:', e) - // 失败时默认隐藏找伙伴(与Web版保持一致) - this.setData({ matchEnabled: false }) + + this.setData({ matchEnabled }, () => { + console.log('[TabBar] ✅ matchEnabled已设置为:', this.data.matchEnabled) + // 配置加载完成后,根据当前路由设置选中状态 + this.updateSelected() + }) + + // 如果当前在找伙伴页面,但功能已关闭,跳转到首页 + if (!matchEnabled) { + const pages = getCurrentPages() + const currentPage = pages[pages.length - 1] + if (currentPage && currentPage.route === 'pages/match/match') { + console.log('[TabBar] 找伙伴功能已关闭,从match页面跳转到首页') + wx.switchTab({ url: '/pages/index/index' }) + } + } + } catch (error) { + console.log('[TabBar] ❌ 加载功能配置失败:', error) + console.log('[TabBar] 错误详情:', error.message || error) + // 默认关闭找伙伴功能 + this.setData({ matchEnabled: false }, () => { + this.updateSelected() + }) } }, - + + // 根据当前路由更新选中状态 + updateSelected() { + const pages = getCurrentPages() + if (pages.length === 0) return + + const currentPage = pages[pages.length - 1] + const route = currentPage.route + + let selected = 0 + const { matchEnabled } = this.data + + // 根据路由匹配对应的索引 + if (route === 'pages/index/index') { + selected = 0 + } else if (route === 'pages/chapters/chapters') { + selected = 1 + } else if (route === 'pages/match/match') { + selected = 2 + } else if (route === 'pages/my/my') { + selected = matchEnabled ? 3 : 2 + } + + this.setData({ selected }) + }, + switchTab(e) { const data = e.currentTarget.dataset const url = data.path diff --git a/miniprogram/custom-tab-bar/index.wxml b/miniprogram/custom-tab-bar/index.wxml index a412ddaf..73369b2a 100644 --- a/miniprogram/custom-tab-bar/index.wxml +++ b/miniprogram/custom-tab-bar/index.wxml @@ -1,17 +1,14 @@ - + - - - - - - - + {{list[0].text}} @@ -19,38 +16,32 @@ - - - - - - - + {{list[1].text}} - - + + - - - - + {{list[2].text}} - + - - - - - - + - {{list[3].text}} + {{list[3].text}} diff --git a/miniprogram/custom-tab-bar/index.wxss b/miniprogram/custom-tab-bar/index.wxss index 84ad115f..98036655 100644 --- a/miniprogram/custom-tab-bar/index.wxss +++ b/miniprogram/custom-tab-bar/index.wxss @@ -18,6 +18,16 @@ z-index: 999; } +/* 三个tab布局(找伙伴功能关闭时) */ +.tab-bar-three .tab-bar-item { + flex: 1; +} + +/* 四个tab布局(找伙伴功能开启时) */ +.tab-bar-four .tab-bar-item { + flex: 1; +} + .tab-bar-border { position: absolute; top: 0; @@ -58,105 +68,18 @@ line-height: 1; } -/* ===== 首页图标 ===== */ -.icon-home { - position: relative; - width: 40rpx; - height: 40rpx; +/* ===== SVG 图标样式 ===== */ +.tab-icon { + width: 48rpx; + height: 48rpx; + display: block; + filter: brightness(0) saturate(100%) invert(60%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(95%) contrast(85%); } -.home-roof { - position: absolute; - top: 4rpx; - left: 50%; - transform: translateX(-50%); - width: 0; - height: 0; - border-left: 18rpx solid transparent; - border-right: 18rpx solid transparent; - border-bottom: 14rpx solid #8e8e93; +.tab-icon.icon-active { + filter: brightness(0) saturate(100%) invert(72%) sepia(54%) saturate(2933%) hue-rotate(134deg) brightness(101%) contrast(101%); } -.home-body { - position: absolute; - bottom: 4rpx; - left: 50%; - transform: translateX(-50%); - width: 28rpx; - height: 18rpx; - background: #8e8e93; - border-radius: 0 0 4rpx 4rpx; -} - -.icon-active .home-roof { - border-bottom-color: #00CED1; -} - -.icon-active .home-body { - background: #00CED1; -} - -/* ===== 目录图标 ===== */ -.icon-list { - width: 36rpx; - height: 32rpx; - display: flex; - flex-direction: column; - justify-content: space-between; -} - -.list-line { - width: 100%; - height: 6rpx; - background: #8e8e93; - border-radius: 3rpx; -} - -.list-line:nth-child(2) { - width: 75%; -} - -.list-line:nth-child(3) { - width: 50%; -} - -.icon-active .list-line { - background: #00CED1; -} - -/* ===== 我的图标 ===== */ -.icon-user { - position: relative; - width: 36rpx; - height: 40rpx; -} - -.user-head { - position: absolute; - top: 0; - left: 50%; - transform: translateX(-50%); - width: 16rpx; - height: 16rpx; - background: #8e8e93; - border-radius: 50%; -} - -.user-body { - position: absolute; - bottom: 0; - left: 50%; - transform: translateX(-50%); - width: 28rpx; - height: 18rpx; - background: #8e8e93; - border-radius: 14rpx 14rpx 0 0; -} - -.icon-active .user-head, -.icon-active .user-body { - background: #00CED1; -} /* ===== 找伙伴 - 中间特殊按钮 ===== */ .special-item { @@ -189,39 +112,10 @@ margin-top: 4rpx; } -/* ===== 找伙伴图标 (双人) ===== */ -.icon-users { - position: relative; - width: 56rpx; - height: 44rpx; -} - -.user-circle { - position: absolute; - width: 28rpx; - height: 28rpx; - border-radius: 50%; - background: #ffffff; -} - -.user-circle::after { - content: ''; - position: absolute; - bottom: -12rpx; - left: 50%; - transform: translateX(-50%); - width: 22rpx; - height: 14rpx; - background: #ffffff; - border-radius: 11rpx 11rpx 0 0; -} - -.user-1 { - top: 0; - left: 0; -} - -.user-2 { - top: 0; - right: 0; +/* ===== 找伙伴特殊按钮图标 ===== */ +.special-icon { + width: 80rpx; + height: 80rpx; + display: block; + filter: brightness(0) saturate(100%) invert(100%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(100%) contrast(100%); } diff --git a/miniprogram/pages/about/about.js b/miniprogram/pages/about/about.js index dbbcabb0..8f19cc60 100644 --- a/miniprogram/pages/about/about.js +++ b/miniprogram/pages/about/about.js @@ -49,7 +49,7 @@ Page({ // 加载书籍统计 async loadBookStats() { try { - const res = await app.request('/api/book/stats') + const res = await app.request('/api/miniprogram/book/stats') if (res && res.success) { this.setData({ 'bookInfo.totalChapters': res.data?.totalChapters || 62, diff --git a/miniprogram/pages/addresses/addresses.js b/miniprogram/pages/addresses/addresses.js new file mode 100644 index 00000000..685528cf --- /dev/null +++ b/miniprogram/pages/addresses/addresses.js @@ -0,0 +1,123 @@ +/** + * 收货地址列表页 + * 参考 Next.js: app/view/my/addresses/page.tsx + */ + +const app = getApp() + +Page({ + data: { + statusBarHeight: 44, + isLoggedIn: false, + addressList: [], + loading: true + }, + + onLoad() { + this.setData({ + statusBarHeight: app.globalData.statusBarHeight || 44 + }) + this.checkLogin() + }, + + onShow() { + if (this.data.isLoggedIn) { + this.loadAddresses() + } + }, + + // 检查登录状态 + checkLogin() { + const isLoggedIn = app.globalData.isLoggedIn + const userId = app.globalData.userInfo?.id + + if (!isLoggedIn || !userId) { + wx.showModal({ + title: '需要登录', + content: '请先登录后再管理收货地址', + confirmText: '去登录', + success: (res) => { + if (res.confirm) { + wx.switchTab({ url: '/pages/my/my' }) + } else { + wx.navigateBack() + } + } + }) + return + } + + this.setData({ isLoggedIn: true }) + this.loadAddresses() + }, + + // 加载地址列表 + async loadAddresses() { + const userId = app.globalData.userInfo?.id + if (!userId) return + + this.setData({ loading: true }) + + try { + const res = await app.request(`/api/miniprogram/user/addresses?userId=${userId}`) + if (res.success && res.list) { + this.setData({ + addressList: res.list, + loading: false + }) + } else { + this.setData({ addressList: [], loading: false }) + } + } catch (e) { + console.error('加载地址列表失败:', e) + this.setData({ loading: false }) + wx.showToast({ title: '加载失败', icon: 'none' }) + } + }, + + // 编辑地址 + editAddress(e) { + const id = e.currentTarget.dataset.id + wx.navigateTo({ url: `/pages/addresses/edit?id=${id}` }) + }, + + // 删除地址 + deleteAddress(e) { + const id = e.currentTarget.dataset.id + + wx.showModal({ + title: '确认删除', + content: '确定要删除该收货地址吗?', + confirmColor: '#FF3B30', + success: async (res) => { + if (res.confirm) { + try { + const result = await app.request(`/api/miniprogram/user/addresses/${id}`, { + method: 'DELETE' + }) + + if (result.success) { + wx.showToast({ title: '删除成功', icon: 'success' }) + this.loadAddresses() + } else { + wx.showToast({ title: result.message || '删除失败', icon: 'none' }) + } + } catch (e) { + console.error('删除地址失败:', e) + wx.showToast({ title: '删除失败', icon: 'none' }) + } + } + } + }) + }, + + // 新增地址 + addAddress() { + wx.navigateTo({ url: '/pages/addresses/edit' }) + }, + + // 返回 + goBack() { + wx.navigateBack() + } +}) diff --git a/miniprogram/pages/addresses/addresses.json b/miniprogram/pages/addresses/addresses.json new file mode 100644 index 00000000..2e45b65e --- /dev/null +++ b/miniprogram/pages/addresses/addresses.json @@ -0,0 +1,5 @@ +{ + "usingComponents": {}, + "navigationStyle": "custom", + "enablePullDownRefresh": false +} diff --git a/miniprogram/pages/addresses/addresses.wxml b/miniprogram/pages/addresses/addresses.wxml new file mode 100644 index 00000000..cec2ef6e --- /dev/null +++ b/miniprogram/pages/addresses/addresses.wxml @@ -0,0 +1,66 @@ + + + + + + + + 收货地址 + + + + + + + + 加载中... + + + + + 📍 + 暂无收货地址 + 点击下方按钮添加 + + + + + + + {{item.name}} + {{item.phone}} + 默认 + + {{item.fullAddress}} + + + ✏️ + 编辑 + + + 🗑️ + 删除 + + + + + + + + + 新增收货地址 + + + diff --git a/miniprogram/pages/addresses/addresses.wxss b/miniprogram/pages/addresses/addresses.wxss new file mode 100644 index 00000000..9ff21637 --- /dev/null +++ b/miniprogram/pages/addresses/addresses.wxss @@ -0,0 +1,217 @@ +/** + * 收货地址列表页样式 + */ + +.page { + min-height: 100vh; + background: #000000; + padding-bottom: 200rpx; +} + +/* ===== 导航栏 ===== */ +.nav-bar { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + background: rgba(0, 0, 0, 0.9); + backdrop-filter: blur(40rpx); + border-bottom: 1rpx solid rgba(255, 255, 255, 0.05); +} + +.nav-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 32rpx; + height: 88rpx; +} + +.nav-back { + width: 64rpx; + height: 64rpx; + border-radius: 50%; + background: rgba(255, 255, 255, 0.1); + display: flex; + align-items: center; + justify-content: center; +} + +.nav-back:active { + background: rgba(255, 255, 255, 0.15); +} + +.back-icon { + font-size: 48rpx; + color: #ffffff; + line-height: 1; +} + +.nav-title { + flex: 1; + text-align: center; + font-size: 36rpx; + font-weight: 600; + color: #ffffff; +} + +.nav-placeholder { + width: 64rpx; +} + +/* ===== 内容区 ===== */ +.content { + padding: 32rpx; +} + +/* ===== 加载状态 ===== */ +.loading-state { + padding: 240rpx 0; + text-align: center; +} + +.loading-text { + font-size: 28rpx; + color: rgba(255, 255, 255, 0.4); +} + +/* ===== 空状态 ===== */ +.empty-state { + padding: 240rpx 0; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; +} + +.empty-icon { + font-size: 96rpx; + margin-bottom: 24rpx; + opacity: 0.3; +} + +.empty-text { + font-size: 28rpx; + color: rgba(255, 255, 255, 0.6); + margin-bottom: 16rpx; +} + +.empty-tip { + font-size: 24rpx; + color: rgba(255, 255, 255, 0.4); +} + +/* ===== 地址列表 ===== */ +.address-list { + margin-bottom: 24rpx; +} + +.address-card { + background: #1c1c1e; + border-radius: 24rpx; + border: 2rpx solid rgba(255, 255, 255, 0.05); + padding: 32rpx; + margin-bottom: 24rpx; +} + +/* 地址头部 */ +.address-header { + display: flex; + align-items: center; + gap: 16rpx; + margin-bottom: 16rpx; +} + +.receiver-name { + font-size: 32rpx; + font-weight: 600; + color: #ffffff; +} + +.receiver-phone { + font-size: 28rpx; + color: rgba(255, 255, 255, 0.5); +} + +.default-tag { + font-size: 22rpx; + color: #00CED1; + background: rgba(0, 206, 209, 0.2); + padding: 6rpx 16rpx; + border-radius: 8rpx; + margin-left: auto; +} + +/* 地址文本 */ +.address-text { + font-size: 28rpx; + color: rgba(255, 255, 255, 0.6); + line-height: 1.6; + display: block; + margin-bottom: 24rpx; + padding-bottom: 24rpx; + border-bottom: 2rpx solid rgba(255, 255, 255, 0.05); +} + +/* 操作按钮 */ +.address-actions { + display: flex; + justify-content: flex-end; + gap: 32rpx; +} + +.action-btn { + display: flex; + align-items: center; + gap: 8rpx; + padding: 8rpx 0; +} + +.action-btn:active { + opacity: 0.6; +} + +.edit-btn { + color: #00CED1; +} + +.delete-btn { + color: #FF3B30; +} + +.action-icon { + font-size: 28rpx; +} + +.action-text { + font-size: 28rpx; +} + +/* ===== 新增按钮 ===== */ +.add-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 16rpx; + padding: 32rpx; + background: #00CED1; + border-radius: 24rpx; + font-weight: 600; + margin-top: 48rpx; +} + +.add-btn:active { + opacity: 0.8; + transform: scale(0.98); +} + +.add-icon { + font-size: 36rpx; + color: #000000; +} + +.add-text { + font-size: 32rpx; + color: #000000; +} diff --git a/miniprogram/pages/addresses/edit.js b/miniprogram/pages/addresses/edit.js new file mode 100644 index 00000000..9542c1dc --- /dev/null +++ b/miniprogram/pages/addresses/edit.js @@ -0,0 +1,201 @@ +/** + * 地址编辑页(新增/编辑) + * 参考 Next.js: app/view/my/addresses/[id]/page.tsx + */ + +const app = getApp() + +Page({ + data: { + statusBarHeight: 44, + isEdit: false, // 是否为编辑模式 + addressId: null, + + // 表单数据 + name: '', + phone: '', + province: '', + city: '', + district: '', + detail: '', + isDefault: false, + + // 地区选择器 + region: [], + + saving: false + }, + + onLoad(options) { + this.setData({ + statusBarHeight: app.globalData.statusBarHeight || 44 + }) + + // 如果有 id 参数,则为编辑模式 + if (options.id) { + this.setData({ + isEdit: true, + addressId: options.id + }) + this.loadAddress(options.id) + } + }, + + // 加载地址详情(编辑模式) + async loadAddress(id) { + wx.showLoading({ title: '加载中...', mask: true }) + + try { + const res = await app.request(`/api/miniprogram/user/addresses/${id}`) + if (res.success && res.data) { + const addr = res.data + this.setData({ + name: addr.name || '', + phone: addr.phone || '', + province: addr.province || '', + city: addr.city || '', + district: addr.district || '', + detail: addr.detail || '', + isDefault: addr.isDefault || false, + region: [addr.province, addr.city, addr.district] + }) + } else { + wx.showToast({ title: '加载失败', icon: 'none' }) + } + } catch (e) { + console.error('加载地址详情失败:', e) + wx.showToast({ title: '加载失败', icon: 'none' }) + } finally { + wx.hideLoading() + } + }, + + // 表单输入 + onNameInput(e) { + this.setData({ name: e.detail.value }) + }, + + onPhoneInput(e) { + this.setData({ phone: e.detail.value.replace(/\D/g, '').slice(0, 11) }) + }, + + onDetailInput(e) { + this.setData({ detail: e.detail.value }) + }, + + // 地区选择 + onRegionChange(e) { + const region = e.detail.value + this.setData({ + region, + province: region[0], + city: region[1], + district: region[2] + }) + }, + + // 切换默认地址 + onDefaultChange(e) { + this.setData({ isDefault: e.detail.value }) + }, + + // 表单验证 + validateForm() { + const { name, phone, province, city, district, detail } = this.data + + if (!name || name.trim().length === 0) { + wx.showToast({ title: '请输入收货人姓名', icon: 'none' }) + return false + } + + if (!phone || phone.length !== 11) { + wx.showToast({ title: '请输入正确的手机号', icon: 'none' }) + return false + } + + if (!province || !city || !district) { + wx.showToast({ title: '请选择省市区', icon: 'none' }) + return false + } + + if (!detail || detail.trim().length === 0) { + wx.showToast({ title: '请输入详细地址', icon: 'none' }) + return false + } + + return true + }, + + // 保存地址 + async saveAddress() { + if (!this.validateForm()) return + if (this.data.saving) return + + this.setData({ saving: true }) + wx.showLoading({ title: '保存中...', mask: true }) + + const { isEdit, addressId, name, phone, province, city, district, detail, isDefault } = this.data + const userId = app.globalData.userInfo?.id + + if (!userId) { + wx.hideLoading() + wx.showToast({ title: '请先登录', icon: 'none' }) + this.setData({ saving: false }) + return + } + + const addressData = { + userId, + name, + phone, + province, + city, + district, + detail, + fullAddress: `${province}${city}${district}${detail}`, + isDefault + } + + try { + let res + if (isEdit) { + // 编辑模式 - PUT 请求 + res = await app.request(`/api/miniprogram/user/addresses/${addressId}`, { + method: 'PUT', + data: addressData + }) + } else { + // 新增模式 - POST 请求 + res = await app.request('/api/miniprogram/user/addresses', { + method: 'POST', + data: addressData + }) + } + + if (res.success) { + wx.hideLoading() + wx.showToast({ + title: isEdit ? '保存成功' : '添加成功', + icon: 'success' + }) + setTimeout(() => { + wx.navigateBack() + }, 1500) + } else { + wx.hideLoading() + wx.showToast({ title: res.message || '保存失败', icon: 'none' }) + this.setData({ saving: false }) + } + } catch (e) { + console.error('保存地址失败:', e) + wx.hideLoading() + wx.showToast({ title: '保存失败', icon: 'none' }) + this.setData({ saving: false }) + } + }, + + // 返回 + goBack() { + wx.navigateBack() + } +}) diff --git a/miniprogram/pages/addresses/edit.json b/miniprogram/pages/addresses/edit.json new file mode 100644 index 00000000..2e45b65e --- /dev/null +++ b/miniprogram/pages/addresses/edit.json @@ -0,0 +1,5 @@ +{ + "usingComponents": {}, + "navigationStyle": "custom", + "enablePullDownRefresh": false +} diff --git a/miniprogram/pages/addresses/edit.wxml b/miniprogram/pages/addresses/edit.wxml new file mode 100644 index 00000000..c5429207 --- /dev/null +++ b/miniprogram/pages/addresses/edit.wxml @@ -0,0 +1,101 @@ + + + + + + + + {{isEdit ? '编辑地址' : '新增地址'}} + + + + + + + + + + 👤 + 收货人 + + + + + + + + 📱 + 手机号 + + + + + + + + 📍 + 所在地区 + + + + {{province || city || district ? province + ' ' + city + ' ' + district : '请选择省市区'}} + + + + + + + + 🏠 + 详细地址 + +