commit ff4c7b7e0917beba8b3f88dd22669c055d49b0d6 Author: zhiyun Date: Mon Feb 2 18:21:32 2026 +0800 feat: 更新小程序代码 - 2026-02-02 diff --git a/README.md b/README.md new file mode 100644 index 0000000..9113f5a --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ +# 中老年人陪伴咨询小程序 + +基于 React 版本转换的微信小程序,保持样式和功能完全一致。 + +## 项目结构 + +``` +miniprogram/ +├── app.js # 小程序入口 +├── app.json # 全局配置 +├── app.wxss # 全局样式 +├── project.config.json # 项目配置 +├── sitemap.json # 站点地图 +├── images/ # 图标资源 +│ ├── icon-*.svg # 页面图标 +│ └── tab-*.png # TabBar 图标(需手动添加) +└── pages/ + ├── index/ # 首页 - 遇见(卡片滑动匹配) + ├── square/ # 广场 - 社交动态 + ├── chat/ # 消息 - 倾听师列表 + ├── chat-detail/ # 聊天详情 + └── profile/ # 我的 - 个人中心 +``` + +## 功能说明 + +### 1. 首页(遇见) +- 好友故事横向滚动列表 +- 卡片堆叠展示用户资料 +- 左右滑动切换(左滑跳过,右滑喜欢) +- 喜欢、语音、选择操作按钮 +- VIP 解锁弹窗 + +### 2. 广场 +- 社交动态列表 +- 发布动态(文字+图片) +- 点赞、评论功能 +- 图片预览 + +### 3. 消息(倾听师) +- 倾听师列表展示 +- 搜索和筛选功能 +- 倾听师详情页 +- 立即咨询功能 + +### 4. 聊天详情 +- 实时聊天界面 +- 文字/语音输入切换 +- 表情面板 +- 自动回复模拟 + +### 5. 我的 +- 用户信息展示 +- 青草余额和充值 +- 功能菜单(喜欢、访客、礼物、订单) +- 设置和帮助 + +## 使用说明 + +### 1. 导入项目 +1. 打开微信开发者工具 +2. 选择「导入项目」 +3. 选择 `miniprogram` 目录 +4. 填写 AppID(可使用测试号) + +### 2. 添加 TabBar 图标 +TabBar 需要 PNG 格式图标,请参考 `images/README.md` 说明手动添加。 + +### 3. 编译运行 +点击「编译」按钮即可预览小程序。 + +## 技术说明 + +### 样式转换 +- React Tailwind CSS → 小程序 WXSS +- 使用 rpx 单位适配不同屏幕 +- CSS 变量转换为具体颜色值 + +### 组件转换 +- React 组件 → 小程序 Page +- useState → Page data +- useEffect → onLoad/onShow +- onClick → bindtap + +### 动画实现 +- Framer Motion → CSS Animation +- 卡片滑动使用 touch 事件 + transform + +## 注意事项 + +1. 图片资源使用网络图片(Unsplash),需要在小程序后台配置域名白名单 +2. TabBar 图标需要手动添加 PNG 格式文件 +3. 部分功能为模拟实现(如支付、语音识别) + +## 域名白名单配置 + +在小程序后台「开发」→「开发设置」→「服务器域名」中添加: + +``` +request合法域名: +- https://images.unsplash.com + +downloadFile合法域名: +- https://images.unsplash.com +``` + +## 版本信息 + +- 小程序基础库:2.25.0+ +- 转换自 React 版本 +- 保持视觉和功能一致性 diff --git a/app.js b/app.js new file mode 100644 index 0000000..c832138 --- /dev/null +++ b/app.js @@ -0,0 +1,537 @@ +// app.js +const api = require('./utils/api') +const auth = require('./utils/auth') +const config = require('./config/index') +const { preloadAssets } = require('./utils/assets') + +App({ + globalData: { + // 用户信息 + userInfo: null, + userId: null, + isLoggedIn: false, + + // 登录状态检查 + loginCheckComplete: false, + loginCheckCallbacks: [], + + // 余额信息 + flowerBalance: 0, + earnings: 0, + + // 系统信息 + systemInfo: null, + statusBarHeight: 44, + navBarHeight: 44, // 导航内容高度(胶囊按钮区域) + totalNavHeight: 88, // 总导航栏高度(状态栏+导航内容) + navHeight: 88, // 兼容旧代码,等同于 totalNavHeight + + // 审核状态 + auditStatus: 0, // 1 表示开启(审核中),0 表示关闭(正式环境) + + // 配置 + config: config + }, + + async onLaunch(options) { + console.log('小程序启动,options:', options) + + // 获取审核状态(同步等待完成,确保页面能获取到正确的审核状态) + await this.loadAuditStatus() + console.log('审核状态加载完成:', this.globalData.auditStatus) + + const defaultBaseUrl = String(config.API_BASE_URL || '').replace(/\/api$/, '') + if (defaultBaseUrl) { + this.globalData.baseUrl = defaultBaseUrl + if (!wx.getStorageSync('baseUrl')) { + wx.setStorageSync('baseUrl', defaultBaseUrl) + } + } + + // 处理邀请码(爱心值系统) + if (options.query && options.query.invite) { + wx.setStorageSync('inviteCode', options.query.invite) + console.log('保存邀请码:', options.query.invite) + } + + // 处理推荐码(佣金系统) + this.handleReferralCode(options) + + // 获取系统信息 + this.initSystemInfo() + + // 预加载页面素材配置 + await preloadAssets() + + // 检查登录状态(支持持久化登录状态恢复) + this.checkLoginStatus() + }, + + onShow(options) { + console.log('小程序显示,options:', options) + + if (options.query && options.query.invite) { + wx.setStorageSync('inviteCode', options.query.invite) + console.log('保存邀请码:', options.query.invite) + } + + this.handleReferralCode(options) + }, + + /** + * 初始化系统信息 + */ + initSystemInfo() { + try { + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + + // 获取胶囊按钮位置信息 + const menuButton = wx.getMenuButtonBoundingClientRect() + + // 正确计算导航栏高度: + // 导航内容高度 = 胶囊按钮高度 + 上下边距 × 2(上下对称) + // 胶囊按钮上边距 = menuButton.top - statusBarHeight + const navBarHeight = menuButton.height + (menuButton.top - statusBarHeight) * 2 + + // 总导航栏高度 = 状态栏高度 + 导航内容高度 + const totalNavHeight = statusBarHeight + navBarHeight + + this.globalData.systemInfo = systemInfo + this.globalData.statusBarHeight = statusBarHeight + this.globalData.navBarHeight = navBarHeight + this.globalData.totalNavHeight = totalNavHeight + this.globalData.navHeight = totalNavHeight // 兼容旧代码 + } catch (e) { + console.error('获取系统信息失败', e) + } + }, + + /** + * 检查登录状态 + * 支持持久化登录状态恢复(7天免登录) + * Requirements: 2.2, 2.3, 2.4 + */ + async checkLoginStatus() { + // 1. 先检查本地登录状态 + if (!auth.isLoggedIn()) { + // 无本地登录状态,清除可能残留的数据 + const cachedUserInfo = wx.getStorageSync(config.STORAGE_KEYS.USER_INFO) + if (cachedUserInfo) { + console.log('无有效Token,清除残留缓存') + auth.clearLoginInfo() + } + this.globalData.isLoggedIn = false + this.globalData.loginCheckComplete = true + this.executeLoginCheckCallbacks() + return + } + + // 2. 有本地登录状态,验证服务端Token + try { + const result = await auth.verifyLogin() + + if (result.valid && result.user) { + // Token有效,恢复登录状态 + this.setUserInfo(result.user) + this.globalData.isLoggedIn = true + + // 获取余额 + this.loadUserBalance() + + console.log('登录状态恢复成功', result.user) + } else { + // Token无效或过期 + if (result.expired) { + console.log('Token已过期,需要重新登录') + } + this.handleTokenInvalid() + } + } catch (err) { + console.log('登录状态验证失败', err) + // 网络错误时保持本地状态,允许离线使用 + const localUser = auth.getLocalUserInfo() + if (localUser) { + this.globalData.userInfo = localUser + this.globalData.userId = localUser.id + this.globalData.isLoggedIn = true + console.log('网络异常,使用本地缓存用户信息') + } else { + this.handleTokenInvalid() + } + } + + // 标记登录检查完成 + this.globalData.loginCheckComplete = true + + // 执行等待中的回调 + this.executeLoginCheckCallbacks() + }, + + /** + * 处理Token无效的情况 + * Requirements: 2.4 + */ + handleTokenInvalid() { + auth.clearLoginInfo() + this.globalData.userInfo = null + this.globalData.userId = null + this.globalData.isLoggedIn = false + this.globalData.flowerBalance = 0 + this.globalData.earnings = 0 + }, + + /** + * 等待登录检查完成 + * @returns {Promise} 登录检查完成后resolve + */ + waitForLoginCheck() { + return new Promise((resolve) => { + if (this.globalData.loginCheckComplete) { + resolve(this.globalData.isLoggedIn) + } else { + this.globalData.loginCheckCallbacks.push(resolve) + } + }) + }, + + /** + * 执行登录检查完成后的回调 + */ + executeLoginCheckCallbacks() { + const callbacks = this.globalData.loginCheckCallbacks + this.globalData.loginCheckCallbacks = [] + callbacks.forEach(callback => { + callback(this.globalData.isLoggedIn) + }) + }, + + /** + * 设置用户信息 + * @param {object} userInfo - 用户信息 + */ + setUserInfo(userInfo) { + this.globalData.userInfo = userInfo + this.globalData.userId = userInfo.id + auth.saveUserInfo(userInfo, null) // 只更新用户信息,不更新token + }, + + /** + * 加载用户余额 + */ + async loadUserBalance() { + try { + const res = await api.user.getBalance() + if (res.success && res.data) { + this.globalData.flowerBalance = res.data.flower_balance || 0 + this.globalData.earnings = res.data.earnings || 0 + } + } catch (err) { + console.error('获取余额失败', err) + } + }, + + /** + * 微信登录 + */ + async wxLogin() { + return new Promise((resolve, reject) => { + wx.login({ + success: async (loginRes) => { + if (loginRes.code) { + try { + const res = await api.auth.wxLogin(loginRes.code) + if (res.success && res.data) { + // 计算过期时间(7天后) + const expiresAt = new Date() + expiresAt.setDate(expiresAt.getDate() + 7) + + // 保存登录信息 + auth.saveUserInfo(res.data.user, res.data.token, expiresAt.toISOString()) + + // 设置全局状态 + this.globalData.userInfo = res.data.user + this.globalData.userId = res.data.user.id + this.globalData.isLoggedIn = true + + // 获取余额 + this.loadUserBalance() + + resolve(res.data) + } else { + reject(res) + } + } catch (err) { + reject(err) + } + } else { + reject({ message: '微信登录失败' }) + } + }, + fail: (err) => { + reject(err) + } + }) + }) + }, + + /** + * 手机号登录 + * @param {string} phone - 手机号 + * @param {string} code - 验证码 + */ + async phoneLogin(phone, code) { + try { + const res = await api.auth.phoneLogin(phone, code) + if (res.success && res.data) { + // 计算过期时间(7天后) + const expiresAt = new Date() + expiresAt.setDate(expiresAt.getDate() + 7) + + // 保存登录信息 + auth.saveUserInfo(res.data.user, res.data.token, expiresAt.toISOString()) + + // 设置全局状态 + this.globalData.userInfo = res.data.user + this.globalData.userId = res.data.user.id + this.globalData.isLoggedIn = true + + // 获取余额 + this.loadUserBalance() + + return res.data + } + throw res + } catch (err) { + throw err + } + }, + + /** + * 微信手机号快速登录 + * @param {string} code - 微信getPhoneNumber返回的code + * Requirements: 1.3, 1.4, 1.5, 2.1 + */ + async wxPhoneLogin(code, loginCode) { + try { + const result = await auth.wxPhoneLogin(code, loginCode) + + if (result.success && result.user) { + // 设置全局状态 + this.globalData.userInfo = result.user + this.globalData.userId = result.user.id + this.globalData.isLoggedIn = true + + // 获取余额 + this.loadUserBalance() + + console.log('微信手机号快速登录成功', result.user) + + return { token: auth.getToken(), user: result.user } + } + throw { message: result.error || '登录失败' } + } catch (err) { + console.error('微信手机号快速登录失败', err) + throw err + } + }, + + /** + * 退出登录 + * Requirements: 2.5 + */ + async logout() { + await auth.logout() + + // 清除全局状态 + this.globalData.userInfo = null + this.globalData.userId = null + this.globalData.isLoggedIn = false + this.globalData.flowerBalance = 0 + this.globalData.earnings = 0 + + console.log('已退出登录,清除所有本地状态') + }, + + /** + * 刷新用户信息 + */ + async refreshUserInfo() { + try { + const res = await api.auth.getCurrentUser() + if (res.success && res.data) { + this.setUserInfo(res.data) + } + } catch (err) { + console.error('刷新用户信息失败', err) + } + }, + + /** + * 检查是否需要登录 + * @param {boolean} showTip - 是否显示提示 + */ + checkNeedLogin(showTip = true) { + if (!this.globalData.isLoggedIn) { + if (showTip) { + wx.showModal({ + title: '提示', + content: '请先登录后再操作', + confirmText: '去登录', + confirmColor: '#b06ab3', + success: (res) => { + if (res.confirm) { + // 跳转到个人中心进行登录 + wx.switchTab({ + url: '/pages/profile/profile' + }) + } + } + }) + } + return true + } + return false + }, + + /** + * 验证当前Token是否有效 + * 用于页面需要确认登录状态时调用 + * Requirements: 2.2, 2.4 + * @returns {Promise} Token是否有效 + */ + async validateToken() { + if (!auth.isLoggedIn()) { + this.globalData.isLoggedIn = false + return false + } + + try { + const result = await auth.verifyLogin() + + if (result.valid && result.user) { + // Token有效,更新用户信息 + this.globalData.userInfo = result.user + this.globalData.userId = result.user.id + this.globalData.isLoggedIn = true + return true + } else { + // Token无效 + this.handleTokenInvalid() + return false + } + } catch (err) { + console.log('Token验证失败', err) + this.handleTokenInvalid() + return false + } + }, + + /** + * 获取登录状态(等待初始化完成) + * 用于页面onLoad时获取准确的登录状态 + * @returns {Promise} 是否已登录 + */ + async getLoginStatus() { + await this.waitForLoginCheck() + return this.globalData.isLoggedIn + }, + + /** + * 加载审核状态 + */ + async loadAuditStatus() { + try { + const res = await api.common.getAuditStatus() + if (res.code === 0 && res.data) { + this.globalData.auditStatus = Number(res.data.auditStatus || 0) + console.log('获取审核状态成功:', this.globalData.auditStatus) + } + } catch (err) { + console.error('获取审核状态失败', err) + // 失败时默认为 0(正式环境),或根据需要设为 1(保守方案) + } + }, + + /** + * 处理推荐码(佣金系统) + * 支持场景: + * 1. 小程序分享:path?referralCode=ABC12345 + * 2. 朋友圈分享:query.referralCode + * 3. 小程序码:scene=r=ABC12345 + */ + handleReferralCode(options) { + let referralCode = null + + if (options.query && options.query.referralCode) { + referralCode = options.query.referralCode + console.log('从query获取推荐码(referralCode):', referralCode) + } else if (options.query && options.query.ref) { + referralCode = options.query.ref + console.log('从query获取推荐码(ref):', referralCode) + } + + if (!referralCode && options.scene) { + referralCode = this.parseSceneReferralCode(options.scene) + if (referralCode) { + console.log('从scene获取推荐码:', referralCode) + } + } + + if (referralCode) { + this.globalData.pendingReferralCode = referralCode + wx.setStorageSync('pendingReferralCode', referralCode) + console.log('推荐码已保存到本地:', referralCode) + + const isLoggedIn = this.globalData.isLoggedIn || !!wx.getStorageSync('auth_token') + if (isLoggedIn) { + console.log('用户已登录,发起即时静默绑定') + const userId = this.globalData.userId || wx.getStorageSync('user_id') + const token = wx.getStorageSync('auth_token') + if (userId && token) { + auth.checkAndBindReferralCode(userId, token) + } + } + } + }, + + /** + * 解析scene参数中的推荐码 + * scene格式:r=ABC12345 + */ + parseSceneReferralCode(scene) { + if (!scene) return null + + try { + const decoded = decodeURIComponent(scene) + const match = decoded.match(/r=([A-Z0-9]+)/) + return match ? match[1] : null + } catch (error) { + console.error('解析scene失败:', error) + return null + } + }, + + /** + * 全局转发配置 + * 用户转发小程序时的默认配置 + */ + onShareAppMessage() { + return { + title: '欢迎来到心伴俱乐部', + desc: '随时可聊 一直陪伴', + path: '/pages/index/index', + imageUrl: '/images/icon-heart-new.png' + } + }, + + /** + * 全局分享到朋友圈配置 + */ + onShareTimeline() { + return { + title: '欢迎来到心伴俱乐部 - 随时可聊 一直陪伴', + imageUrl: '/images/icon-heart-new.png' + } + } +}) diff --git a/app.json b/app.json new file mode 100644 index 0000000..5993e03 --- /dev/null +++ b/app.json @@ -0,0 +1,117 @@ +{ + "pages": [ + "pages/index/index", + "pages/entertainment/entertainment", + "pages/singles-party/singles-party", + "pages/city-selector/city-selector", + "pages/interest-partner/interest-partner", + "pages/city-activities/city-activities", + "pages/activity-detail/activity-detail", + "pages/outdoor-activities/outdoor-activities", + "pages/theme-travel/theme-travel", + "pages/happy-school/happy-school", + "pages/companion-chat/companion-chat", + "pages/service/service", + "pages/service-provider-detail/service-provider-detail", + "pages/chat/chat", + "pages/chat-detail/chat-detail", + "pages/profile/profile", + "pages/login/login", + "pages/recharge/recharge", + "pages/character-detail/character-detail", + "pages/counselor-detail/counselor-detail", + "pages/orders/orders", + "pages/order-detail/order-detail", + "pages/my-activities/my-activities", + "pages/edit-profile/edit-profile", + "pages/customer-management/customer-management", + "pages/withdraw/withdraw", + "pages/commission/commission", + "pages/promote/promote", + "pages/backpack/backpack", + "pages/square/square", + "pages/workbench/workbench", + "pages/companion-apply/companion-apply", + "pages/companion-orders/companion-orders", + "pages/agreement/agreement", + "pages/medical-apply/medical-apply", + "pages/housekeeping-apply/housekeeping-apply", + "pages/eldercare-apply/eldercare-apply", + "pages/entertainment-apply/entertainment-apply", + "pages/invite/invite", + "pages/love-transactions/love-transactions", + "pages/gift-shop/gift-shop", + "pages/gift-detail/gift-detail", + "pages/gift-exchanges/gift-exchanges", + "pages/referrals/referrals", + "pages/referrals/orders/orders", + "pages/withdraw-records/withdraw-records", + "pages/support/support", + "pages/settings/settings", + "pages/team/team", + "pages/performance/performance", + "pages/promote-poster/promote-poster", + "pages/academy/list/list", + "pages/academy/detail/detail", + "pages/webview/webview", + "pages/brand/brand", + "pages/eldercare/eldercare", + "pages/custom/custom", + "pages/notices/notices", + "pages/notices/detail/detail" + ], + "subpackages": [ + { + "root": "subpackages/cooperation", + "pages": [ + "pages/cooperation/cooperation" + ] + } + ], + "__usePrivacyCheck__": false, + "window": { + "navigationBarBackgroundColor": "#E8C3D4", + "navigationBarTitleText": "", + "navigationBarTextStyle": "black", + "backgroundColor": "#E8C3D4", + "backgroundTextStyle": "dark", + "navigationStyle": "custom" + }, + "tabBar": { + "custom": false, + "color": "#a58aa5", + "selectedColor": "#b06ab3", + "backgroundColor": "#ffffff", + "borderStyle": "white", + "list": [ + { + "pagePath": "pages/index/index", + "text": "陪伴" + }, + { + "pagePath": "pages/entertainment/entertainment", + "text": "文娱" + }, + { + "pagePath": "pages/service/service", + "text": "服务" + }, + { + "pagePath": "pages/chat/chat", + "text": "消息" + }, + { + "pagePath": "pages/profile/profile", + "text": "我的" + } + ] + }, + "style": "v2", + "sitemapLocation": "sitemap.json", + "lazyCodeLoading": "requiredComponents", + "permission": { + "scope.writePhotosAlbum": { + "desc": "保存二维码到相册" + } + } +} \ No newline at end of file diff --git a/app.wxss b/app.wxss new file mode 100644 index 0000000..567bfa4 --- /dev/null +++ b/app.wxss @@ -0,0 +1,200 @@ +/* 统一导航栏样式 */ +.unified-header { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 194rpx; + background: rgba(255, 255, 255, 0.95); + border-bottom: 2rpx solid #f9fafb; + display: flex; + align-items: flex-end; + justify-content: space-between; + padding: 0 32rpx 20rpx; + z-index: 1000; +} + +.unified-header-left { + display: flex; + align-items: center; + gap: 8rpx; + width: 160rpx; + height: 56rpx; +} + +.unified-header-title { + font-size: 40rpx; + font-weight: bold; + color: #101828; + flex: 1; + text-align: center; +} + +.unified-header-right { + width: 160rpx; + height: 56rpx; +} + +.unified-back-icon { + width: 56rpx; + height: 56rpx; +} + +.unified-back-text { + font-size: 34rpx; + font-weight: bold; + color: #101828; +} + +/* 隐藏原生tabBar边框 */ +.wx-tabbar::before { + display: none !important; +} + +/* 全局样式 */ +page { + --primary: #914584; + --primary-light: #E8C3D4; + --background: #ffffff; + --foreground: #1a1a1a; + --muted: #ececf0; + --muted-foreground: #717182; + --border: rgba(0, 0, 0, 0.1); + --radius: 20rpx; + + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background-color: var(--primary-light); + color: var(--foreground); + font-size: 28rpx; + line-height: 1.5; + width: 100%; + max-width: 100vw; + overflow-x: hidden; + box-sizing: border-box; +} + +/* 全局盒模型 */ +view, text, image, scroll-view, input { + box-sizing: border-box; + max-width: 100%; +} + +.btn-reset { + padding: 0; + margin: 0; + line-height: inherit; + background: transparent; + border-radius: 0; + text-align: center; +} +.btn-reset::after { border: none; } + +.safe-bottom { + padding-bottom: constant(safe-area-inset-bottom); + padding-bottom: env(safe-area-inset-bottom); +} + +/* 通用类 */ +.container { + padding: 30rpx; +} + +.flex { + display: flex; +} + +.flex-col { + flex-direction: column; +} + +.items-center { + align-items: center; +} + +.justify-center { + justify-content: center; +} + +.justify-between { + justify-content: space-between; +} + +.gap-20 { + gap: 20rpx; +} + +.gap-30 { + gap: 30rpx; +} + +.text-center { + text-align: center; +} + +.font-bold { + font-weight: bold; +} + +.rounded-full { + border-radius: 50%; +} + +.shadow { + box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.1); +} + +/* 卡片样式 */ +.card { + background: #fff; + border-radius: 32rpx; + padding: 30rpx; + box-shadow: 0 8rpx 40rpx rgba(59, 64, 86, 0.15); +} + +/* 按钮样式 */ +.btn-primary { + background: linear-gradient(135deg, #914584 0%, #B378FE 100%); + color: #fff; + border: none; + border-radius: 50rpx; + padding: 24rpx 48rpx; + font-size: 32rpx; + font-weight: bold; +} + +.btn-secondary { + background: #f5f5f5; + color: #333; + border: none; + border-radius: 50rpx; + padding: 24rpx 48rpx; + font-size: 32rpx; +} + +/* 头像样式 */ +.avatar { + width: 120rpx; + height: 120rpx; + border-radius: 50%; + object-fit: cover; +} + +.avatar-sm { + width: 80rpx; + height: 80rpx; +} + +.avatar-lg { + width: 160rpx; + height: 160rpx; +} + +/* 动画 */ +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.animate-pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} diff --git a/components/icon/icon.js b/components/icon/icon.js new file mode 100644 index 0000000..e4d0af9 --- /dev/null +++ b/components/icon/icon.js @@ -0,0 +1,61 @@ +const svgToDataUrl = (svg) => { + const encoded = encodeURIComponent(svg) + .replace(/'/g, '%27') + .replace(/"/g, '%22'); + return `data:image/svg+xml,${encoded}`; +}; + +const ICONS = { + 'chevron-left': '', + 'chevron-right': '', + 'settings': '', + 'crown': '', + 'gift': '', + 'diamond': '', + 'clock': '', + 'wallet': '', + 'sprout': '', + 'banknote': '', + 'shopping-bag': '', + 'users': '', + 'backpack': '', + 'share': '', + 'briefcase': '', + 'headphones': '', + 'package': '', + 'logout': '', + 'copy': '', + 'image': '', + 'share-2': '', + 'scan': '', + 'camera': '', + 'clipboard': '', + 'trending-up': '', + 'map-pin': '', + 'heart': '', + 'heart-filled': '', + 'flame': '', + 'flame-hot': '' +}; + +Component({ + properties: { + name: { type: String, value: '' }, + size: { type: Number, value: 24 }, + color: { type: String, value: '#111827' } + }, + data: { + style: '' + }, + observers: { + 'name,size,color': function(name, size, color) { + const raw = ICONS[name] || ''; + const svg = raw.replace(/CURRENT/g, color || '#111827'); + const url = svg ? svgToDataUrl(svg) : ''; + const s = Number(size || 24); + this.setData({ + style: `width:${s}rpx;height:${s}rpx;${url ? `background-image:url('${url}');` : ''}` + }); + } + } +}); diff --git a/components/icon/icon.json b/components/icon/icon.json new file mode 100644 index 0000000..467ce29 --- /dev/null +++ b/components/icon/icon.json @@ -0,0 +1,3 @@ +{ + "component": true +} diff --git a/components/icon/icon.wxml b/components/icon/icon.wxml new file mode 100644 index 0000000..9f40565 --- /dev/null +++ b/components/icon/icon.wxml @@ -0,0 +1 @@ + diff --git a/components/icon/icon.wxss b/components/icon/icon.wxss new file mode 100644 index 0000000..a8cda1b --- /dev/null +++ b/components/icon/icon.wxss @@ -0,0 +1,6 @@ +.icon { + display: inline-block; + background-repeat: no-repeat; + background-position: center; + background-size: contain; +} diff --git a/config/index.js b/config/index.js new file mode 100644 index 0000000..0058237 --- /dev/null +++ b/config/index.js @@ -0,0 +1,59 @@ +/** + * 小程序配置文件 + * 包含API地址、环境配置等 + */ + +// 环境配置 +const ENV = { + // 开发环境 + development: { + API_BASE_URL: 'http://localhost:3000/api', + WS_URL: 'ws://localhost:3000', + DEBUG: true, + TEST_MODE: true // 测试模式:充值不走真实支付 + }, + // 测试环境 + staging: { + API_BASE_URL: 'https://test-api.maimanji.com/api', + WS_URL: 'wss://test-api.maimanji.com', + DEBUG: true, + TEST_MODE: true // 测试模式:充值不走真实支付 + }, + // 生产环境 + production: { + API_BASE_URL: 'https://ai-c.maimanji.com/api', + WS_URL: 'wss://ai-c.maimanji.com', + DEBUG: false, + TEST_MODE: false // 关闭测试模式,使用真实微信支付(后端测试支付接口有数据库错误) + } +} + +// 当前环境 - 可根据需要切换 +// development: 本地开发 staging: 测试环境 production: 正式环境 +const CURRENT_ENV = 'production' + +// 导出配置 +const config = { + ...ENV[CURRENT_ENV], + ENV: CURRENT_ENV, + + // 存储键名 + STORAGE_KEYS: { + TOKEN: 'auth_token', + REFRESH_TOKEN: 'refresh_token', + USER_INFO: 'user_info', + USER_ID: 'user_id', + TOKEN_EXPIRY: 'token_expiry' // Token过期时间 + }, + + // 请求超时时间(毫秒) + REQUEST_TIMEOUT: 30000, + + // 分页默认配置 + PAGE_SIZE: 20, + + // 版本号 + VERSION: '1.0.0' +} + +module.exports = config diff --git a/git-push.bat b/git-push.bat new file mode 100644 index 0000000..7181697 --- /dev/null +++ b/git-push.bat @@ -0,0 +1,34 @@ +@echo off +chcp 65001 >nul +echo ======================== +echo Git 提交脚本 +echo ======================== +echo. + +cd /d "%~dp0" + +echo [1/5] 添加所有更改... +git add -A + +echo. +echo [2/5] 提交更改... +git commit -m "feat: 更新小程序代码 - 2026-02-02" + +echo. +echo [3/5] 添加 tag... +git tag -a v1.0.0 -m "Version 1.0.0 - 2026-02-02" + +echo. +echo [4/5] 推送到远程仓库... +echo 请输入密码: zy12345678 +git push https://zhiyun:zy12345678@git.maimanji.com/adminzy/ai-c.git master --force + +echo. +echo [5/5] 推送 tag... +git push https://zhiyun:zy12345678@git.maimanji.com/adminzy/ai-c.git v1.0.0 + +echo. +echo ======================== +echo 完成! +echo ======================== +pause diff --git a/git-push.sh b/git-push.sh new file mode 100644 index 0000000..b59368b --- /dev/null +++ b/git-push.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# Git 提交脚本 + +cd "$(dirname "$0")" + +echo "========================" +echo "Git 提交脚本" +echo "========================" +echo "" + +echo "[1/5] 添加所有更改..." +git add -A + +echo "" +echo "[2/5] 提交更改..." +git commit -m "feat: 更新小程序代码 - 2026-02-02" + +echo "" +echo "[3/5] 添加 tag..." +git tag -a v1.0.0 -m "Version 1.0.0 - 2026-02-02" + +echo "" +echo "[4/5] 推送到远程仓库..." +git push https://zhiyun:zy12345678@git.maimanji.com/adminzy/ai-c.git master --force + +echo "" +echo "[5/5] 推送 tag..." +git push https://zhiyun:zy12345678@git.maimanji.com/adminzy/ai-c.git v1.0.0 + +echo "" +echo "========================" +echo "完成!" +echo "========================" diff --git a/images/README.md b/images/README.md new file mode 100644 index 0000000..734c336 --- /dev/null +++ b/images/README.md @@ -0,0 +1,74 @@ +# 图标资源说明 + +✅ 页面图标已生成完成(SVG 格式) + +## TabBar 图标(需要手动添加 PNG 格式,81x81px) + +由于微信小程序 TabBar 只支持 PNG 格式,需要手动创建以下图标: + +- `tab-heart.png` - 遇见(未选中,灰色 #999999) +- `tab-heart-active.png` - 遇见(选中,紫色 #914584) +- `tab-compass.png` - 广场(未选中) +- `tab-compass-active.png` - 广场(选中) +- `tab-message.png` - 消息(未选中) +- `tab-message-active.png` - 消息(选中) +- `tab-user.png` - 我的(未选中) +- `tab-user-active.png` - 我的(选中) + +### 快速生成 TabBar 图标方法: +1. 访问 https://lucide.dev/icons/ +2. 搜索对应图标(heart, compass, message-circle, user) +3. 下载 SVG 后使用在线工具转换为 PNG +4. 推荐工具:https://svgtopng.com/ + +## 页面图标(SVG 格式推荐) +- `icon-bell.svg` - 通知铃铛 +- `icon-chevron-left.svg` - 左箭头 +- `icon-chevron-right.svg` - 右箭头 +- `icon-chevron-down.svg` - 下箭头 +- `icon-heart.svg` - 爱心(空心) +- `icon-heart-filled.svg` - 爱心(实心) +- `icon-voice.svg` - 语音/声波 +- `icon-check.svg` - 勾选 +- `icon-refresh.svg` - 刷新 +- `icon-lock.svg` - 锁 +- `icon-grass.svg` - 青草/植物 +- `icon-plus.svg` - 加号 +- `icon-more.svg` - 更多(三个点) +- `icon-comment.svg` - 评论 +- `icon-headphones.svg` - 耳机 +- `icon-search.svg` - 搜索 +- `icon-mic.svg` - 麦克风 +- `icon-star.svg` - 星星 +- `icon-verified.svg` - 认证标识 +- `icon-phone.svg` - 电话 +- `icon-back.svg` - 返回 +- `icon-keyboard.svg` - 键盘 +- `icon-emoji.svg` - 表情 +- `icon-send.svg` - 发送 +- `icon-settings.svg` - 设置 +- `icon-camera.svg` - 相机 +- `icon-eye.svg` - 眼睛 +- `icon-gift.svg` - 礼物 +- `icon-order.svg` - 订单 +- `icon-help.svg` - 帮助 +- `icon-feedback.svg` - 反馈 +- `icon-info.svg` - 信息 + +## 图标颜色建议 +- 未选中状态:#999999 +- 选中状态:#914584(主题紫色) +- 白色图标:#FFFFFF + +## 获取图标 +可以从以下网站获取免费图标: +1. [Lucide Icons](https://lucide.dev/) - 推荐,与 React 版本一致 +2. [Heroicons](https://heroicons.com/) +3. [Feather Icons](https://feathericons.com/) +4. [阿里巴巴矢量图标库](https://www.iconfont.cn/) + +## 快速生成方法 +1. 访问 https://lucide.dev/icons/ +2. 搜索对应图标名称 +3. 下载 SVG 格式 +4. 修改颜色后保存到此目录 diff --git a/images/btn-consult.png b/images/btn-consult.png new file mode 100644 index 0000000..a3240d1 Binary files /dev/null and b/images/btn-consult.png differ diff --git a/images/btn-text-consult.png b/images/btn-text-consult.png new file mode 100644 index 0000000..e072ebf Binary files /dev/null and b/images/btn-text-consult.png differ diff --git a/images/chat-action-camera.png b/images/chat-action-camera.png new file mode 100644 index 0000000..feff19e Binary files /dev/null and b/images/chat-action-camera.png differ diff --git a/images/chat-action-gift.png b/images/chat-action-gift.png new file mode 100644 index 0000000..b5f361e Binary files /dev/null and b/images/chat-action-gift.png differ diff --git a/images/chat-action-photo.png b/images/chat-action-photo.png new file mode 100644 index 0000000..11eba31 Binary files /dev/null and b/images/chat-action-photo.png differ diff --git a/images/chat-input-emoji.png b/images/chat-input-emoji.png new file mode 100644 index 0000000..f5c63c2 Binary files /dev/null and b/images/chat-input-emoji.png differ diff --git a/images/chat-input-plus.png b/images/chat-input-plus.png new file mode 100644 index 0000000..91dce10 Binary files /dev/null and b/images/chat-input-plus.png differ diff --git a/images/chat-input-voice.png b/images/chat-input-voice.png new file mode 100644 index 0000000..b127986 Binary files /dev/null and b/images/chat-input-voice.png differ diff --git a/images/default-avatar.svg b/images/default-avatar.svg new file mode 100644 index 0000000..f7a6be7 --- /dev/null +++ b/images/default-avatar.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/images/figma-gift-shop-chevron-right.svg b/images/figma-gift-shop-chevron-right.svg new file mode 100644 index 0000000..0efbaa0 --- /dev/null +++ b/images/figma-gift-shop-chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/images/figma-gift-shop-decor-icon.svg b/images/figma-gift-shop-decor-icon.svg new file mode 100644 index 0000000..ab065be --- /dev/null +++ b/images/figma-gift-shop-decor-icon.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/images/figma-gift-shop-love-icon.svg b/images/figma-gift-shop-love-icon.svg new file mode 100644 index 0000000..9bcc89d --- /dev/null +++ b/images/figma-gift-shop-love-icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/images/figma-gift-shop-price-icon.svg b/images/figma-gift-shop-price-icon.svg new file mode 100644 index 0000000..e3d727a --- /dev/null +++ b/images/figma-gift-shop-price-icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/images/figma-gift-shop-section-icon.svg b/images/figma-gift-shop-section-icon.svg new file mode 100644 index 0000000..8d93f24 --- /dev/null +++ b/images/figma-gift-shop-section-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/images/fuw-aixin.png b/images/fuw-aixin.png new file mode 100644 index 0000000..5a3934d Binary files /dev/null and b/images/fuw-aixin.png differ diff --git a/images/fuw-dingzhi.png b/images/fuw-dingzhi.png new file mode 100644 index 0000000..7305f13 Binary files /dev/null and b/images/fuw-dingzhi.png differ diff --git a/images/fuw-kangyang.png b/images/fuw-kangyang.png new file mode 100644 index 0000000..fae8c4f Binary files /dev/null and b/images/fuw-kangyang.png differ diff --git a/images/fuw-pinpai.png b/images/fuw-pinpai.png new file mode 100644 index 0000000..f06c0ee Binary files /dev/null and b/images/fuw-pinpai.png differ diff --git a/images/fuw-shangcheng.png b/images/fuw-shangcheng.png new file mode 100644 index 0000000..46a4e9f Binary files /dev/null and b/images/fuw-shangcheng.png differ diff --git a/images/fuw-shangjia.png b/images/fuw-shangjia.png new file mode 100644 index 0000000..e3e039a Binary files /dev/null and b/images/fuw-shangjia.png differ diff --git a/images/gift-arrow.png b/images/gift-arrow.png new file mode 100644 index 0000000..f803d9d Binary files /dev/null and b/images/gift-arrow.png differ diff --git a/images/gift-balloon.png b/images/gift-balloon.png new file mode 100644 index 0000000..3287534 Binary files /dev/null and b/images/gift-balloon.png differ diff --git a/images/gift-cake.png b/images/gift-cake.png new file mode 100644 index 0000000..90feb48 Binary files /dev/null and b/images/gift-cake.png differ diff --git a/images/gift-cheer.png b/images/gift-cheer.png new file mode 100644 index 0000000..003ec0c Binary files /dev/null and b/images/gift-cheer.png differ diff --git a/images/gift-chicken.png b/images/gift-chicken.png new file mode 100644 index 0000000..16168df Binary files /dev/null and b/images/gift-chicken.png differ diff --git a/images/gift-cup.png b/images/gift-cup.png new file mode 100644 index 0000000..ed51d7b Binary files /dev/null and b/images/gift-cup.png differ diff --git a/images/gift-diamond.png b/images/gift-diamond.png new file mode 100644 index 0000000..091e5b8 Binary files /dev/null and b/images/gift-diamond.png differ diff --git a/images/gift-heart.png b/images/gift-heart.png new file mode 100644 index 0000000..90eeab2 Binary files /dev/null and b/images/gift-heart.png differ diff --git a/images/gift-hug.png b/images/gift-hug.png new file mode 100644 index 0000000..ce37fa8 Binary files /dev/null and b/images/gift-hug.png differ diff --git a/images/gift-milk.png b/images/gift-milk.png new file mode 100644 index 0000000..1b692d5 Binary files /dev/null and b/images/gift-milk.png differ diff --git a/images/gift-pot.png b/images/gift-pot.png new file mode 100644 index 0000000..87e179b Binary files /dev/null and b/images/gift-pot.png differ diff --git a/images/gift-rice.png b/images/gift-rice.png new file mode 100644 index 0000000..e91855a Binary files /dev/null and b/images/gift-rice.png differ diff --git a/images/gift-rose.png b/images/gift-rose.png new file mode 100644 index 0000000..fcabe14 Binary files /dev/null and b/images/gift-rose.png differ diff --git a/images/gift-scarf.png b/images/gift-scarf.png new file mode 100644 index 0000000..b702b40 Binary files /dev/null and b/images/gift-scarf.png differ diff --git a/images/icon-arrow-right-pink.png b/images/icon-arrow-right-pink.png new file mode 100644 index 0000000..62f5ab3 --- /dev/null +++ b/images/icon-arrow-right-pink.png @@ -0,0 +1 @@ +PNG placeholder - 需要用设计工具导出实际PNG图标 \ No newline at end of file diff --git a/images/icon-arrow-right.png b/images/icon-arrow-right.png new file mode 100644 index 0000000..0542676 --- /dev/null +++ b/images/icon-arrow-right.png @@ -0,0 +1,5 @@ + + + + + diff --git a/images/icon-back-arrow.png b/images/icon-back-arrow.png new file mode 100644 index 0000000..1201d0f Binary files /dev/null and b/images/icon-back-arrow.png differ diff --git a/images/icon-back-arrow2.png b/images/icon-back-arrow2.png new file mode 100644 index 0000000..32f4401 --- /dev/null +++ b/images/icon-back-arrow2.png @@ -0,0 +1,5 @@ + + + + + diff --git a/images/icon-back-line.png b/images/icon-back-line.png new file mode 100644 index 0000000..e95605a Binary files /dev/null and b/images/icon-back-line.png differ diff --git a/images/icon-back.png b/images/icon-back.png new file mode 100644 index 0000000..ee30ccf Binary files /dev/null and b/images/icon-back.png differ diff --git a/images/icon-backpack.png b/images/icon-backpack.png new file mode 100644 index 0000000..1ad2838 Binary files /dev/null and b/images/icon-backpack.png differ diff --git a/images/icon-bell.png b/images/icon-bell.png new file mode 100644 index 0000000..ba620b2 Binary files /dev/null and b/images/icon-bell.png differ diff --git a/images/icon-calendar.png b/images/icon-calendar.png new file mode 100644 index 0000000..94b7688 Binary files /dev/null and b/images/icon-calendar.png differ diff --git a/images/icon-camera.png b/images/icon-camera.png new file mode 100644 index 0000000..fe29531 Binary files /dev/null and b/images/icon-camera.png differ diff --git a/images/icon-check.png b/images/icon-check.png new file mode 100644 index 0000000..a9ef171 Binary files /dev/null and b/images/icon-check.png differ diff --git a/images/icon-checkin.png b/images/icon-checkin.png new file mode 100644 index 0000000..97eeaec Binary files /dev/null and b/images/icon-checkin.png differ diff --git a/images/icon-chevron-down.png b/images/icon-chevron-down.png new file mode 100644 index 0000000..58eb851 Binary files /dev/null and b/images/icon-chevron-down.png differ diff --git a/images/icon-chevron-left.png b/images/icon-chevron-left.png new file mode 100644 index 0000000..e57ade8 Binary files /dev/null and b/images/icon-chevron-left.png differ diff --git a/images/icon-chevron-right.png b/images/icon-chevron-right.png new file mode 100644 index 0000000..32644fb Binary files /dev/null and b/images/icon-chevron-right.png differ diff --git a/images/icon-city.png b/images/icon-city.png new file mode 100644 index 0000000..120b035 Binary files /dev/null and b/images/icon-city.png differ diff --git a/images/icon-cleaning.png b/images/icon-cleaning.png new file mode 100644 index 0000000..f71627c --- /dev/null +++ b/images/icon-cleaning.png @@ -0,0 +1,6 @@ + + + + + + diff --git a/images/icon-clock.png b/images/icon-clock.png new file mode 100644 index 0000000..94b7688 Binary files /dev/null and b/images/icon-clock.png differ diff --git a/images/icon-close.png b/images/icon-close.png new file mode 100644 index 0000000..2c6e025 Binary files /dev/null and b/images/icon-close.png differ diff --git a/images/icon-comment.png b/images/icon-comment.png new file mode 100644 index 0000000..6ad9f72 Binary files /dev/null and b/images/icon-comment.png differ diff --git a/images/icon-companion.png b/images/icon-companion.png new file mode 100644 index 0000000..c76dddb Binary files /dev/null and b/images/icon-companion.png differ diff --git a/images/icon-cooperation.png b/images/icon-cooperation.png new file mode 100644 index 0000000..62f5ab3 --- /dev/null +++ b/images/icon-cooperation.png @@ -0,0 +1 @@ +PNG placeholder - 需要用设计工具导出实际PNG图标 \ No newline at end of file diff --git a/images/icon-coupon.png b/images/icon-coupon.png new file mode 100644 index 0000000..7465f28 Binary files /dev/null and b/images/icon-coupon.png differ diff --git a/images/icon-edit.png b/images/icon-edit.png new file mode 100644 index 0000000..d6ac08e Binary files /dev/null and b/images/icon-edit.png differ diff --git a/images/icon-eldercare.png b/images/icon-eldercare.png new file mode 100644 index 0000000..62f5ab3 --- /dev/null +++ b/images/icon-eldercare.png @@ -0,0 +1 @@ +PNG placeholder - 需要用设计工具导出实际PNG图标 \ No newline at end of file diff --git a/images/icon-elderly.png b/images/icon-elderly.png new file mode 100644 index 0000000..620054e --- /dev/null +++ b/images/icon-elderly.png @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/images/icon-emoji-face.png b/images/icon-emoji-face.png new file mode 100644 index 0000000..0aa6f74 Binary files /dev/null and b/images/icon-emoji-face.png differ diff --git a/images/icon-emoji-gray.svg b/images/icon-emoji-gray.svg new file mode 100644 index 0000000..6a51c79 --- /dev/null +++ b/images/icon-emoji-gray.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/images/icon-emoji.png b/images/icon-emoji.png new file mode 100644 index 0000000..16e7e92 Binary files /dev/null and b/images/icon-emoji.png differ diff --git a/images/icon-empty.png b/images/icon-empty.png new file mode 100644 index 0000000..11dc43b Binary files /dev/null and b/images/icon-empty.png differ diff --git a/images/icon-errand.png b/images/icon-errand.png new file mode 100644 index 0000000..f55a614 --- /dev/null +++ b/images/icon-errand.png @@ -0,0 +1,7 @@ + + + + + + + diff --git a/images/icon-eye.png b/images/icon-eye.png new file mode 100644 index 0000000..0f42b6e Binary files /dev/null and b/images/icon-eye.png differ diff --git a/images/icon-feedback.png b/images/icon-feedback.png new file mode 100644 index 0000000..22defb3 Binary files /dev/null and b/images/icon-feedback.png differ diff --git a/images/icon-gift.png b/images/icon-gift.png new file mode 100644 index 0000000..1e4c786 Binary files /dev/null and b/images/icon-gift.png differ diff --git a/images/icon-grass.png b/images/icon-grass.png new file mode 100644 index 0000000..d564379 Binary files /dev/null and b/images/icon-grass.png differ diff --git a/images/icon-headphones.png b/images/icon-headphones.png new file mode 100644 index 0000000..3fb55c5 Binary files /dev/null and b/images/icon-headphones.png differ diff --git a/images/icon-heart-filled.png b/images/icon-heart-filled.png new file mode 100644 index 0000000..b7dcbe6 Binary files /dev/null and b/images/icon-heart-filled.png differ diff --git a/images/icon-heart-listen.png b/images/icon-heart-listen.png new file mode 100644 index 0000000..62f5ab3 --- /dev/null +++ b/images/icon-heart-listen.png @@ -0,0 +1 @@ +PNG placeholder - 需要用设计工具导出实际PNG图标 \ No newline at end of file diff --git a/images/icon-heart-new.png b/images/icon-heart-new.png new file mode 100644 index 0000000..37e2c11 Binary files /dev/null and b/images/icon-heart-new.png differ diff --git a/images/icon-heart.png b/images/icon-heart.png new file mode 100644 index 0000000..b7dcbe6 Binary files /dev/null and b/images/icon-heart.png differ diff --git a/images/icon-heart_.png b/images/icon-heart_.png new file mode 100644 index 0000000..ec5a401 Binary files /dev/null and b/images/icon-heart_.png differ diff --git a/images/icon-help.png b/images/icon-help.png new file mode 100644 index 0000000..33908b6 Binary files /dev/null and b/images/icon-help.png differ diff --git a/images/icon-hospital.png b/images/icon-hospital.png new file mode 100644 index 0000000..565fee3 --- /dev/null +++ b/images/icon-hospital.png @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/images/icon-housekeeping.png b/images/icon-housekeeping.png new file mode 100644 index 0000000..62f5ab3 --- /dev/null +++ b/images/icon-housekeeping.png @@ -0,0 +1 @@ +PNG placeholder - 需要用设计工具导出实际PNG图标 \ No newline at end of file diff --git a/images/icon-info.png b/images/icon-info.png new file mode 100644 index 0000000..e06476e Binary files /dev/null and b/images/icon-info.png differ diff --git a/images/icon-interest.png b/images/icon-interest.png new file mode 100644 index 0000000..c914594 Binary files /dev/null and b/images/icon-interest.png differ diff --git a/images/icon-keyboard.png b/images/icon-keyboard.png new file mode 100644 index 0000000..d912e4c Binary files /dev/null and b/images/icon-keyboard.png differ diff --git a/images/icon-legal.png b/images/icon-legal.png new file mode 100644 index 0000000..30230b1 --- /dev/null +++ b/images/icon-legal.png @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/images/icon-listen.png b/images/icon-listen.png new file mode 100644 index 0000000..f28be6e --- /dev/null +++ b/images/icon-listen.png @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/images/icon-location-pin.png b/images/icon-location-pin.png new file mode 100644 index 0000000..e1b0655 Binary files /dev/null and b/images/icon-location-pin.png differ diff --git a/images/icon-location.png b/images/icon-location.png new file mode 100644 index 0000000..1deb9b4 Binary files /dev/null and b/images/icon-location.png differ diff --git a/images/icon-lock.png b/images/icon-lock.png new file mode 100644 index 0000000..a039693 Binary files /dev/null and b/images/icon-lock.png differ diff --git a/images/icon-logout.png b/images/icon-logout.png new file mode 100644 index 0000000..b51ea2c Binary files /dev/null and b/images/icon-logout.png differ diff --git a/images/icon-love.png b/images/icon-love.png new file mode 100644 index 0000000..d6b6873 Binary files /dev/null and b/images/icon-love.png differ diff --git a/images/icon-medical.png b/images/icon-medical.png new file mode 100644 index 0000000..62f5ab3 --- /dev/null +++ b/images/icon-medical.png @@ -0,0 +1 @@ +PNG placeholder - 需要用设计工具导出实际PNG图标 \ No newline at end of file diff --git a/images/icon-mic.png b/images/icon-mic.png new file mode 100644 index 0000000..3706418 Binary files /dev/null and b/images/icon-mic.png differ diff --git a/images/icon-more-dot.png b/images/icon-more-dot.png new file mode 100644 index 0000000..92ae9bb Binary files /dev/null and b/images/icon-more-dot.png differ diff --git a/images/icon-more.png b/images/icon-more.png new file mode 100644 index 0000000..8d160cd Binary files /dev/null and b/images/icon-more.png differ diff --git a/images/icon-notification.png b/images/icon-notification.png new file mode 100644 index 0000000..abefec6 --- /dev/null +++ b/images/icon-notification.png @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/images/icon-order-complete.png b/images/icon-order-complete.png new file mode 100644 index 0000000..01e87eb Binary files /dev/null and b/images/icon-order-complete.png differ diff --git a/images/icon-order-paid.png b/images/icon-order-paid.png new file mode 100644 index 0000000..6f903a9 Binary files /dev/null and b/images/icon-order-paid.png differ diff --git a/images/icon-order.png b/images/icon-order.png new file mode 100644 index 0000000..5ccc239 Binary files /dev/null and b/images/icon-order.png differ diff --git a/images/icon-outdoor.png b/images/icon-outdoor.png new file mode 100644 index 0000000..e3f0163 Binary files /dev/null and b/images/icon-outdoor.png differ diff --git a/images/icon-phone-white.png b/images/icon-phone-white.png new file mode 100644 index 0000000..66215b1 Binary files /dev/null and b/images/icon-phone-white.png differ diff --git a/images/icon-phone.png b/images/icon-phone.png new file mode 100644 index 0000000..8e86d19 Binary files /dev/null and b/images/icon-phone.png differ diff --git a/images/icon-plus.png b/images/icon-plus.png new file mode 100644 index 0000000..1b1211d Binary files /dev/null and b/images/icon-plus.png differ diff --git a/images/icon-promote.png b/images/icon-promote.png new file mode 100644 index 0000000..576b944 Binary files /dev/null and b/images/icon-promote.png differ diff --git a/images/icon-refresh.png b/images/icon-refresh.png new file mode 100644 index 0000000..72e389a Binary files /dev/null and b/images/icon-refresh.png differ diff --git a/images/icon-search.png b/images/icon-search.png new file mode 100644 index 0000000..1a3273a Binary files /dev/null and b/images/icon-search.png differ diff --git a/images/icon-send.png b/images/icon-send.png new file mode 100644 index 0000000..ef96fdd Binary files /dev/null and b/images/icon-send.png differ diff --git a/images/icon-settings.png b/images/icon-settings.png new file mode 100644 index 0000000..f7ee37d Binary files /dev/null and b/images/icon-settings.png differ diff --git a/images/icon-share.png b/images/icon-share.png new file mode 100644 index 0000000..c1c0302 Binary files /dev/null and b/images/icon-share.png differ diff --git a/images/icon-star.png b/images/icon-star.png new file mode 100644 index 0000000..9fd7c41 Binary files /dev/null and b/images/icon-star.png differ diff --git a/images/icon-test.png b/images/icon-test.png new file mode 100644 index 0000000..5eae09a Binary files /dev/null and b/images/icon-test.png differ diff --git a/images/icon-travel.png b/images/icon-travel.png new file mode 100644 index 0000000..da7781f Binary files /dev/null and b/images/icon-travel.png differ diff --git a/images/icon-trending-up.png b/images/icon-trending-up.png new file mode 100644 index 0000000..00a2cb4 Binary files /dev/null and b/images/icon-trending-up.png differ diff --git a/images/icon-users.png b/images/icon-users.png new file mode 100644 index 0000000..877b89e Binary files /dev/null and b/images/icon-users.png differ diff --git a/images/icon-verified.png b/images/icon-verified.png new file mode 100644 index 0000000..511617f Binary files /dev/null and b/images/icon-verified.png differ diff --git a/images/icon-vip.png b/images/icon-vip.png new file mode 100644 index 0000000..7b19eac Binary files /dev/null and b/images/icon-vip.png differ diff --git a/images/icon-voice.png b/images/icon-voice.png new file mode 100644 index 0000000..a297341 Binary files /dev/null and b/images/icon-voice.png differ diff --git a/images/icon-wallet.png b/images/icon-wallet.png new file mode 100644 index 0000000..7a73c60 Binary files /dev/null and b/images/icon-wallet.png differ diff --git a/images/service-arrow-right.png b/images/service-arrow-right.png new file mode 100644 index 0000000..86cdff9 Binary files /dev/null and b/images/service-arrow-right.png differ diff --git a/images/service-clock.png b/images/service-clock.png new file mode 100644 index 0000000..e66b831 Binary files /dev/null and b/images/service-clock.png differ diff --git a/images/service-notification.png b/images/service-notification.png new file mode 100644 index 0000000..406ea07 Binary files /dev/null and b/images/service-notification.png differ diff --git a/images/service-search.png b/images/service-search.png new file mode 100644 index 0000000..321c8e7 Binary files /dev/null and b/images/service-search.png differ diff --git a/images/service-star.png b/images/service-star.png new file mode 100644 index 0000000..2927e98 Binary files /dev/null and b/images/service-star.png differ diff --git a/images/service-type-custom.png b/images/service-type-custom.png new file mode 100644 index 0000000..bf39044 Binary files /dev/null and b/images/service-type-custom.png differ diff --git a/images/service-type-eldercare.png b/images/service-type-eldercare.png new file mode 100644 index 0000000..9f56707 Binary files /dev/null and b/images/service-type-eldercare.png differ diff --git a/images/service-type-housekeeping.png b/images/service-type-housekeeping.png new file mode 100644 index 0000000..7a4e651 Binary files /dev/null and b/images/service-type-housekeeping.png differ diff --git a/images/service-type-leisure.png b/images/service-type-leisure.png new file mode 100644 index 0000000..b2b197a Binary files /dev/null and b/images/service-type-leisure.png differ diff --git a/images/service-type-listen.png b/images/service-type-listen.png new file mode 100644 index 0000000..d9f320b Binary files /dev/null and b/images/service-type-listen.png differ diff --git a/images/service-type-medical.png b/images/service-type-medical.png new file mode 100644 index 0000000..2182f71 Binary files /dev/null and b/images/service-type-medical.png differ diff --git a/images/tab-chat-active.png b/images/tab-chat-active.png new file mode 100644 index 0000000..128513a Binary files /dev/null and b/images/tab-chat-active.png differ diff --git a/images/tab-chat.png b/images/tab-chat.png new file mode 100644 index 0000000..342133c Binary files /dev/null and b/images/tab-chat.png differ diff --git a/images/tab-companion-active.png b/images/tab-companion-active.png new file mode 100644 index 0000000..23561b9 Binary files /dev/null and b/images/tab-companion-active.png differ diff --git a/images/tab-companion.png b/images/tab-companion.png new file mode 100644 index 0000000..ebe08e9 Binary files /dev/null and b/images/tab-companion.png differ diff --git a/images/tab-compass-active.png b/images/tab-compass-active.png new file mode 100644 index 0000000..a7fe6f7 Binary files /dev/null and b/images/tab-compass-active.png differ diff --git a/images/tab-compass.png b/images/tab-compass.png new file mode 100644 index 0000000..eae081a Binary files /dev/null and b/images/tab-compass.png differ diff --git a/images/tab-heart-active.png b/images/tab-heart-active.png new file mode 100644 index 0000000..55e2668 Binary files /dev/null and b/images/tab-heart-active.png differ diff --git a/images/tab-heart.png b/images/tab-heart.png new file mode 100644 index 0000000..da1523f Binary files /dev/null and b/images/tab-heart.png differ diff --git a/images/tab-listen-active.png b/images/tab-listen-active.png new file mode 100644 index 0000000..e7ecc04 Binary files /dev/null and b/images/tab-listen-active.png differ diff --git a/images/tab-listen-new.png b/images/tab-listen-new.png new file mode 100644 index 0000000..df35d1a Binary files /dev/null and b/images/tab-listen-new.png differ diff --git a/images/tab-listen.png b/images/tab-listen.png new file mode 100644 index 0000000..1b09a5e Binary files /dev/null and b/images/tab-listen.png differ diff --git a/images/tab-message-active-nodot.png b/images/tab-message-active-nodot.png new file mode 100644 index 0000000..fe4c072 Binary files /dev/null and b/images/tab-message-active-nodot.png differ diff --git a/images/tab-message-active.png b/images/tab-message-active.png new file mode 100644 index 0000000..d8569b8 Binary files /dev/null and b/images/tab-message-active.png differ diff --git a/images/tab-message-nodot.png b/images/tab-message-nodot.png new file mode 100644 index 0000000..03ecc22 Binary files /dev/null and b/images/tab-message-nodot.png differ diff --git a/images/tab-message.png b/images/tab-message.png new file mode 100644 index 0000000..08c9017 Binary files /dev/null and b/images/tab-message.png differ diff --git a/images/tab-profile-active.png b/images/tab-profile-active.png new file mode 100644 index 0000000..1f81690 Binary files /dev/null and b/images/tab-profile-active.png differ diff --git a/images/tab-profile.png b/images/tab-profile.png new file mode 100644 index 0000000..556f422 Binary files /dev/null and b/images/tab-profile.png differ diff --git a/images/tab-service-active.png b/images/tab-service-active.png new file mode 100644 index 0000000..ad2af3c Binary files /dev/null and b/images/tab-service-active.png differ diff --git a/images/tab-service.png b/images/tab-service.png new file mode 100644 index 0000000..b5eebbc Binary files /dev/null and b/images/tab-service.png differ diff --git a/images/tab-user-active.png b/images/tab-user-active.png new file mode 100644 index 0000000..7fd8b17 Binary files /dev/null and b/images/tab-user-active.png differ diff --git a/images/tab-user.png b/images/tab-user.png new file mode 100644 index 0000000..da5f6fc Binary files /dev/null and b/images/tab-user.png differ diff --git a/images/wenyu-type-02.png b/images/wenyu-type-02.png new file mode 100644 index 0000000..bd088f8 Binary files /dev/null and b/images/wenyu-type-02.png differ diff --git a/pages/academy/detail/detail.js b/pages/academy/detail/detail.js new file mode 100644 index 0000000..ec31bc6 --- /dev/null +++ b/pages/academy/detail/detail.js @@ -0,0 +1,68 @@ +const api = require('../../../utils/api') + +Page({ + data: { + article: null, + loading: false + }, + + onLoad(options) { + const id = options.id + if (id) { + this.loadArticle(id) + } + }, + + onBack() { + wx.navigateBack() + }, + + async loadArticle(id) { + this.setData({ loading: true }) + + try { + const res = await api.happySchool.getArticleDetail(id) + console.log('[academy-detail] 文章详情响应:', res) + + if (res.success && res.data) { + const article = res.data + this.setData({ + article: { + id: article.id, + title: article.title, + cover: this.processImageUrl(article.coverImage), + content: article.content, + date: this.formatDate(article.publishTime), + author: article.author || '心伴康养', + views: article.readCount || 0 + } + }) + } + } catch (err) { + console.error('[academy-detail] 加载文章详情失败:', err) + wx.showToast({ + title: '加载失败', + icon: 'none' + }) + } finally { + this.setData({ loading: false }) + } + }, + + processImageUrl(url) { + if (!url) return '' + if (url.startsWith('http://') || url.startsWith('https://')) { + return url + } + return 'https://ai-c.maimanji.com' + (url.startsWith('/') ? '' : '/') + url + }, + + formatDate(dateStr) { + if (!dateStr) return '' + const date = new Date(dateStr) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` + } +}) diff --git a/pages/academy/detail/detail.json b/pages/academy/detail/detail.json new file mode 100644 index 0000000..d6f0d0b --- /dev/null +++ b/pages/academy/detail/detail.json @@ -0,0 +1,6 @@ +{ + "usingComponents": { + "app-icon": "/components/icon/icon" + }, + "navigationBarTitleText": "文章详情" +} \ No newline at end of file diff --git a/pages/academy/detail/detail.wxml b/pages/academy/detail/detail.wxml new file mode 100644 index 0000000..651ff2a --- /dev/null +++ b/pages/academy/detail/detail.wxml @@ -0,0 +1,33 @@ + + + + + + 返回 + + 文章详情 + + + + + + + {{article.title}} + + {{article.date}} + {{article.author || '心伴康养'}} + + + + + + + + + + + 阅读 {{article.views}} + + + + diff --git a/pages/academy/detail/detail.wxss b/pages/academy/detail/detail.wxss new file mode 100644 index 0000000..eb4523a --- /dev/null +++ b/pages/academy/detail/detail.wxss @@ -0,0 +1,127 @@ +.page { + min-height: 100vh; + background-color: #fff; + display: flex; + flex-direction: column; +} + +.unified-header { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 194rpx; + background: linear-gradient(135deg, #B06AB3 0%, #9B59B6 100%); + border-bottom: none; + display: flex; + align-items: flex-end; + justify-content: space-between; + padding: 0 32rpx 20rpx; + z-index: 1000; +} + +.unified-header-left { + display: flex; + align-items: center; + gap: 8rpx; + width: 160rpx; + height: 56rpx; +} + +.unified-back-icon { + width: 56rpx; + height: 56rpx; + color: #ffffff; +} + +.unified-back-text { + font-size: 34rpx; + font-weight: bold; + color: #ffffff; +} + +.unified-header-title { + font-size: 40rpx; + font-weight: bold; + color: #ffffff; + flex: 1; + text-align: center; +} + +.unified-header-right { + width: 160rpx; + height: 56rpx; +} + +.content { + flex: 1; +} + +.article-content { + padding: 30rpx; +} + +.header { + margin-bottom: 30rpx; +} + +.title { + font-size: 36rpx; + font-weight: bold; + color: #333; + line-height: 1.4; + margin-bottom: 20rpx; +} + +.meta { + font-size: 24rpx; + color: #999; +} + +.meta text { + margin-right: 20rpx; +} + +.author { + font-size: 30rpx; + color: #576b95; +} + +.cover { + width: 100%; + border-radius: 12rpx; + margin-bottom: 30rpx; +} + +.body { + font-size: 36rpx; + color: #444; + line-height: 1.6; +} + +.body h3 { + font-size: 42rpx; + font-weight: bold; + color: #333; + margin: 30rpx 0 15rpx; + display: block; +} + +.body p { + margin-bottom: 20rpx; + display: block; +} + +.date{ + font-size: 30rpx; +} + +.footer { + margin-top: 50rpx; + padding-bottom: 50rpx; +} + +.read-count { + font-size: 36rpx; + color: #999; +} diff --git a/pages/academy/list/list.js b/pages/academy/list/list.js new file mode 100644 index 0000000..744aa39 --- /dev/null +++ b/pages/academy/list/list.js @@ -0,0 +1,156 @@ +const api = require('../../../utils/api') + +Page({ + data: { + articles: [], + categories: [], + activeCategory: null, + page: 1, + hasMore: true, + loading: false, + error: null + }, + + onLoad() { + this.loadCategories() + this.loadArticles() + }, + + onBack() { + wx.navigateBack() + }, + + onArticleTap(e) { + const id = e.currentTarget.dataset.id + wx.navigateTo({ + url: `/pages/academy/detail/detail?id=${id}` + }) + }, + + async loadCategories() { + try { + const res = await api.happySchool.getCategories() + console.log('[happy-school] 分类响应:', res) + + if (res.success && res.data) { + let categories = [] + + if (res.data.length > 0 && res.data[0].id !== null) { + categories = [ + { id: null, name: '全部' }, + ...res.data.map(cat => ({ + id: cat.id, + name: cat.name + })) + ] + } else { + categories = res.data.map(cat => ({ + id: cat.id, + name: cat.name + })) + } + + this.setData({ categories }) + } + } catch (err) { + console.error('[happy-school] 加载分类失败:', err) + } + }, + + async loadArticles(reset = true) { + if (this.data.loading) return + if (!reset && !this.data.hasMore) return + + this.setData({ loading: true, error: null }) + const page = reset ? 1 : this.data.page + 1 + + try { + const params = { + page, + limit: 20 + } + + if (this.data.activeCategory) { + params.categoryId = this.data.activeCategory + } + + console.log('[happy-school] 请求文章列表:', params) + const res = await api.happySchool.getArticles(params) + console.log('[happy-school] 文章响应:', res) + + if (res.success && res.data) { + let list = [] + + if (Array.isArray(res.data)) { + list = res.data + } else if (res.data.data && Array.isArray(res.data.data)) { + list = res.data.data + } else if (res.data.list && Array.isArray(res.data.list)) { + list = res.data.list + } + + const articles = list.map(item => ({ + id: item.id, + title: item.title, + summary: item.summary || '', + cover: this.processImageUrl(item.coverImage), + date: this.formatDate(item.publishTime), + views: item.readCount || 0, + categoryName: item.categoryName || '' + })) + + this.setData({ + articles: reset ? articles : [...this.data.articles, ...articles], + page, + hasMore: articles.length >= params.limit, + loading: false + }) + } else { + this.setData({ + loading: false, + error: res.error || '加载失败' + }) + } + } catch (err) { + console.error('[happy-school] 加载文章失败:', err) + this.setData({ + loading: false, + error: err.message || '加载失败' + }) + } + }, + + onCategoryTap(e) { + const categoryId = e.currentTarget.dataset.id + if (categoryId === this.data.activeCategory) return + + this.setData({ + activeCategory: categoryId, + page: 1, + hasMore: true, + articles: [] + }) + this.loadArticles(true) + }, + + processImageUrl(url) { + if (!url) return '' + if (url.startsWith('http://') || url.startsWith('https://')) { + return url + } + return 'https://ai-c.maimanji.com' + (url.startsWith('/') ? '' : '/') + url + }, + + formatDate(dateStr) { + if (!dateStr) return '' + const date = new Date(dateStr) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` + }, + + onReachBottom() { + this.loadArticles(false) + } +}) diff --git a/pages/academy/list/list.json b/pages/academy/list/list.json new file mode 100644 index 0000000..c8c2357 --- /dev/null +++ b/pages/academy/list/list.json @@ -0,0 +1,6 @@ +{ + "usingComponents": { + "app-icon": "/components/icon/icon" + }, + "navigationBarTitleText": "心伴学堂" +} \ No newline at end of file diff --git a/pages/academy/list/list.wxml b/pages/academy/list/list.wxml new file mode 100644 index 0000000..6eba3ee --- /dev/null +++ b/pages/academy/list/list.wxml @@ -0,0 +1,56 @@ + + + + + + 返回 + + 心伴学堂 + + + + + + + + + + {{item.name}} + + + + + + + + + + + + + + 加载中... + + + + + 暂无内容 + + + + {{error}} + 点击重试 + + + diff --git a/pages/academy/list/list.wxss b/pages/academy/list/list.wxss new file mode 100644 index 0000000..835befe --- /dev/null +++ b/pages/academy/list/list.wxss @@ -0,0 +1,180 @@ +.page { + min-height: 100vh; + background-color: #f8f8f8; + display: flex; + flex-direction: column; +} + +.unified-header { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 194rpx; + background: linear-gradient(135deg, #B06AB3 0%, #9B59B6 100%); + border-bottom: none; + display: flex; + align-items: flex-end; + justify-content: space-between; + padding: 0 32rpx 20rpx; + z-index: 1000; +} + +.unified-header-left { + display: flex; + align-items: center; + gap: 8rpx; + width: 160rpx; + height: 56rpx; +} + +.unified-back-icon { + width: 56rpx; + height: 56rpx; +} + +.unified-back-text { + font-size: 34rpx; + font-weight: bold; + color: #ffffff; +} + +.unified-header-title { + font-size: 40rpx; + font-weight: bold; + color: #ffffff; + flex: 1; + text-align: center; +} + +.unified-header-right { + width: 160rpx; + height: 56rpx; +} + +.content { + flex: 1; +} + +.category-bar { + background: #fff; + padding: 20rpx 0; + border-bottom: 1rpx solid #eee; +} + +.category-scroll { + white-space: nowrap; +} + +.category-list { + display: inline-flex; + padding: 0 30rpx; +} + +.category-item { + display: inline-block; + padding: 16rpx 32rpx; + font-size: 30rpx; + color: #666; + background: #f5f5f5; + border-radius: 36rpx; + margin-right: 20rpx; + flex-shrink: 0; +} + +.category-item.active { + background: linear-gradient(135deg, #B06AB3 0%, #9B59B6 100%); + color: #fff; +} + +.article-list { + padding: 20rpx 30rpx; +} + +.article-item { + display: flex; + background-color: #fff; + border-radius: 20rpx; + padding: 20rpx; + margin-bottom: 20rpx; + box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05); +} + +.article-cover { + width: 200rpx; + height: 150rpx; + border-radius: 12rpx; + margin-right: 20rpx; + flex-shrink: 0; + background-color: #eee; +} + +.article-info { + flex: 1; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.article-title { + font-size: 38rpx; + font-weight: bold; + color: #333; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + overflow: hidden; +} + +.article-desc { + font-size: 30rpx; + color: #666; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + margin: 10rpx 0; +} + +.article-footer { + display: flex; + justify-content: space-between; + align-items: center; +} + +.article-date, .article-views { + font-size: 28rpx; + color: #999; +} + +.empty-tip, .loading-tip, .error-tip { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding-top: 200rpx; +} + +.empty-tip image { + width: 240rpx; + height: 240rpx; + margin-bottom: 20rpx; +} + +.empty-tip text, .loading-tip text, .error-tip text { + font-size: 28rpx; + color: #999; +} + +.error-tip text:first-child { + color: #ff6b6b; + margin-bottom: 20rpx; +} + +.retry-btn { + background: linear-gradient(135deg, #B06AB3 0%, #9B59B6 100%); + color: #fff !important; + padding: 16rpx 48rpx; + border-radius: 36rpx; + font-size: 28rpx !important; +} diff --git a/pages/activity-detail/activity-detail.js b/pages/activity-detail/activity-detail.js new file mode 100644 index 0000000..d4a6614 --- /dev/null +++ b/pages/activity-detail/activity-detail.js @@ -0,0 +1,767 @@ +// pages/activity-detail/activity-detail.js - 活动详情页面 +const api = require('../../utils/api') +const util = require('../../utils/util') +const imageUrl = require('../../utils/imageUrl') +const app = getApp() + +Page({ + data: { + statusBarHeight: 44, + navBarHeight: 44, + totalNavHeight: 88, + loading: false, + + // 活动ID + activityId: '', + + // 活动详情 + activity: { + id: '', + title: '', + cover_image: '', + description: '', + start_date: '', + start_time: '', + end_date: '', + end_time: '', + address: '', + venue: '', + province: '', + city: '', + district: '', + organizer: '', + contact_phone: '', + price: 0, + is_free: true, + price_text: '', + participants_count: 0, + max_participants: 0, + status: 'upcoming', // upcoming/ongoing/ended/full + is_favorited: false, + images: [] + }, + + // 状态文字 + statusText: '即将开始', + signupButtonText: '立即报名', + + // 参与者列表(显示前6个) + participants: [], + + // 所有参与者(用于弹窗) + allParticipants: [], + + // 显示参与者弹窗 + showParticipantsModal: false, + + // 二维码引导弹窗 + showQrcodeModal: false, + qrcodeImageUrl: '', + + // 推荐活动列表 + recommendList: [] + }, + + onLoad(options) { + // 计算导航栏高度 + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + const menuButton = wx.getMenuButtonBoundingClientRect() + const navBarHeight = menuButton.height + (menuButton.top - statusBarHeight) * 2 + const totalNavHeight = statusBarHeight + navBarHeight + + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight + }) + + // 获取活动ID + if (options.id) { + this.setData({ activityId: options.id }) + this.loadActivityDetail() + this.loadParticipants() + this.loadRecommendList() + } else { + wx.showToast({ + title: '活动不存在', + icon: 'none' + }) + setTimeout(() => { + wx.navigateBack() + }, 1500) + } + }, + + /** + * 提取HTML中的所有图片URL + */ + extractImageUrls(html) { + if (!html) return [] + + const urls = [] + const imgRegex = /]+src="([^"]+)"[^>]*>/gi + let match + + while ((match = imgRegex.exec(html)) !== null) { + urls.push(match[1]) + } + + return urls + }, + + /** + * 处理HTML中的图片样式 + */ + processHtmlImages(html) { + if (!html) return '' + + let imageIndex = 0 + + // 给所有img标签添加样式,确保图片不超出屏幕宽度 + // 注意:rich-text中需要使用px单位,不能使用rpx + return html.replace( + /]*?)(?:\s+style="[^"]*")?([^>]*)>/gi, + (match, before, after) => { + // 移除原有的style属性,添加新的样式和data-index + const cleanBefore = before.replace(/\s+style="[^"]*"/gi, '') + const cleanAfter = after.replace(/\s+style="[^"]*"/gi, '') + const result = `` + imageIndex++ + return result + } + ) + }, + + /** + * 返回上一页 + */ + onBack() { + wx.navigateBack() + }, + + /** + * 分享 + */ + onShare() { + wx.showShareMenu({ + withShareTicket: true, + menus: ['shareAppMessage', 'shareTimeline'] + }) + }, + + /** + * 分享给好友 + */ + onShareAppMessage() { + const { activity } = this.data + const referralCode = wx.getStorageSync('referralCode') || '' + const referralCodeParam = referralCode ? `&referralCode=${referralCode}` : '' + return { + title: activity.title, + path: `/pages/activity-detail/activity-detail?id=${activity.id}${referralCodeParam}`, + imageUrl: activity.cover_image + } + }, + + /** + * 分享到朋友圈 + */ + onShareTimeline() { + const { activity } = this.data + const referralCode = wx.getStorageSync('referralCode') || '' + const query = `id=${activity.id}${referralCode ? `&referralCode=${referralCode}` : ''}` + return { + title: activity.title, + query: query, + imageUrl: activity.cover_image + } + }, + + /** + * 加载活动详情 + */ + async loadActivityDetail() { + this.setData({ loading: true }) + + try { + const res = await api.activity.getDetail(this.data.activityId) + + if (res.success && res.data) { + const activity = { + ...res.data, + is_favorited: res.data.isLiked || res.data.is_favorited // 兼容后端字段 + } + + console.log('[activity-detail] 活动详情数据:', activity) + + // 处理图片数组 + let images = [] + if (activity.images) { + images = typeof activity.images === 'string' + ? JSON.parse(activity.images) + : activity.images + } + + // 格式化日期时间(兼容多种字段名) + const startDate = this.formatDate( + activity.startDate || activity.start_date || activity.activityDate + ) + const startTime = this.formatTime(activity.startTime || activity.start_time || '') + const endDate = activity.endDate || activity.end_date || activity.activityDate || '' + const endTime = activity.endTime || activity.end_time || '' + + // 处理封面图片URL - 后端已返回完整URL,前端只需兜底处理 + const coverImage = imageUrl.getActivityCoverUrl(activity.coverImage || activity.cover_image) + + // 处理地址信息 + const province = activity.province || '' + const city = activity.city || '' + const district = activity.district || '' + const venue = activity.venue || '' + const address = activity.address || `${province}${city}${district}${venue}` + + // 处理主办方信息(兼容对象和字符串格式) + let organizer = '主办方' + if (activity.organizer) { + if (typeof activity.organizer === 'object' && activity.organizer.nickname) { + organizer = activity.organizer.nickname + } else if (typeof activity.organizer === 'string') { + organizer = activity.organizer + } + } + + // 处理活动详情内容 + let description = activity.description || '暂无活动详情' + let detailHtml = '' + let detailImageUrls = [] + + // 如果有detailContent(HTML格式),使用rich-text渲染 + if (activity.detailContent) { + // 提取HTML中的所有图片URL + detailImageUrls = this.extractImageUrls(activity.detailContent) + // 处理HTML,添加图片样式和点击事件标记 + detailHtml = this.processHtmlImages(activity.detailContent) + } else if (activity.description) { + description = activity.description + } + + // 判断活动状态(兼容多种字段名) + const status = this.getActivityStatus({ + ...activity, + start_date: activity.startDate || activity.start_date || activity.activityDate, + start_time: activity.startTime || activity.start_time || '00:00', + end_date: activity.endDate || activity.end_date || activity.activityDate, + end_time: activity.endTime || activity.end_time || '23:59', + max_participants: activity.maxParticipants || activity.max_participants || 0, + participants_count: activity.currentParticipants || activity.participants_count || 0 + }) + const statusText = this.getStatusText(status) + const signupButtonText = this.getSignupButtonText(status) + + this.setData({ + activity: { + id: activity.id, + title: activity.title || '活动标题', + cover_image: coverImage, + description: description, + detailHtml: detailHtml, + detailImageUrls: detailImageUrls, + start_date: startDate, + start_time: startTime, + end_date: endDate, + end_time: endTime, + address: address, + venue: venue, + province: province, + city: city, + district: district, + organizer: organizer, + contact_phone: activity.contactInfo || activity.contact_phone || '', + price: activity.price || 0, + is_free: activity.isFree !== undefined ? activity.isFree : activity.is_free, + price_text: activity.priceText || activity.price_text || '免费', + participants_count: activity.currentParticipants || activity.participants_count || 0, + max_participants: activity.maxParticipants || activity.max_participants || 0, + is_favorited: activity.isLiked || activity.is_favorited || false, + images: images, + status: status, + activity_guide_qrcode: activity.activity_guide_qrcode || activity.activityGuideQrcode || '' + }, + statusText, + signupButtonText, + qrcodeImageUrl: activity.activity_guide_qrcode || activity.activityGuideQrcode || '' + }) + + console.log('[activity-detail] 封面图片URL:', coverImage) + console.log('[activity-detail] 活动地址:', address) + console.log('[activity-detail] 主办方:', organizer) + console.log('[activity-detail] 详情HTML:', detailHtml ? '有HTML内容' : '无HTML内容') + } + } catch (err) { + console.error('加载活动详情失败', err) + wx.showToast({ + title: '加载失败', + icon: 'none' + }) + } finally { + this.setData({ loading: false }) + } + }, + + /** + * 加载参与者列表 + */ + async loadParticipants() { + try { + const res = await api.activity.getParticipants(this.data.activityId, { + page: 1, + limit: 50 + }) + + if (res.success && res.data) { + const allParticipants = res.data.list.map(item => ({ + id: item.id, + name: item.real_name || item.nickname || '匿名用户', + avatar: item.avatar || '/images/default-avatar.png', + join_time: this.formatJoinTime(item.created_at) + })) + + // 前6个用于列表显示 + const participants = allParticipants.slice(0, 6) + + this.setData({ + participants, + allParticipants + }) + } + } catch (err) { + console.log('加载参与者列表失败', err) + } + }, + + /** + * 加载推荐活动 + */ + async loadRecommendList() { + try { + const res = await api.activity.getList({ + limit: 10, // 获取10个,前端只显示8个 + sortBy: 'date' + }) + + console.log('[activity-detail] 推荐活动API响应:', res) + + if (res.success && res.data && res.data.list) { + const recommendList = res.data.list + .filter(item => item.id !== this.data.activityId) // 排除当前活动 + .slice(0, 8) // 只取前8个 + .map(item => { + // 处理封面图片URL - 后端已返回完整URL,前端只需兜底处理 + const coverImage = imageUrl.getActivityCoverUrl(item.coverImage || item.cover_image) + + // 处理日期字段(兼容多种命名) + const startDate = item.startDate || item.start_date || item.activityDate || '' + + console.log('[activity-detail] 推荐活动:', item.title, '封面:', coverImage, '日期:', startDate) + + return { + id: item.id, + title: item.title || '活动标题', + cover_image: coverImage, + start_date: this.formatDate(startDate) + } + }) + + console.log('[activity-detail] 推荐活动列表:', recommendList) + this.setData({ recommendList }) + } + } catch (err) { + console.error('加载推荐活动失败', err) + } + }, + + /** + * 判断活动状态 + */ + getActivityStatus(activity) { + const now = new Date() + const startDate = new Date(activity.start_date + ' ' + activity.start_time) + const endDate = new Date(activity.end_date + ' ' + activity.end_time) + + // 已满员 + if (activity.max_participants > 0 && + activity.participants_count >= activity.max_participants) { + return 'full' + } + + // 已结束 + if (now > endDate) { + return 'ended' + } + + // 进行中 + if (now >= startDate && now <= endDate) { + return 'ongoing' + } + + // 即将开始 + return 'upcoming' + }, + + /** + * 获取状态文字 + */ + getStatusText(status) { + const statusMap = { + upcoming: '即将开始', + ongoing: '进行中', + ended: '已结束', + full: '已满员' + } + return statusMap[status] || '即将开始' + }, + + /** + * 获取报名按钮文字 + */ + getSignupButtonText(status) { + const buttonTextMap = { + upcoming: '立即报名', + ongoing: '立即报名', + ended: '活动已结束', + full: '已满员' + } + return buttonTextMap[status] || '立即报名' + }, + + /** + * 格式化日期 + */ + formatDate(dateStr) { + if (!dateStr) return '' + const date = new Date(dateStr) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}年${month}月${day}日` + }, + + /** + * 格式化时间 + */ + formatTime(timeStr) { + if (!timeStr) return '' + return timeStr.substring(0, 5) // HH:mm + }, + + /** + * 格式化加入时间 + */ + formatJoinTime(dateStr) { + if (!dateStr) return '' + return util.formatTime(dateStr, 'MM-DD HH:mm') + }, + + /** + * 拨打电话 + */ + onCallPhone() { + const phone = this.data.activity.contact_phone + if (!phone) return + + wx.makePhoneCall({ + phoneNumber: phone + }) + }, + + /** + * 预览图片 + */ + onPreviewImage(e) { + const url = e.currentTarget.dataset.url + const urls = this.data.activity.images + + wx.previewImage({ + current: url, + urls: urls + }) + }, + + /** + * 点击详情区域的图片(rich-text中的图片) + */ + onDetailImageTap(e) { + // 获取点击位置 + const { clientX, clientY } = e.detail || e.touches[0] || {} + + // 通过createSelectorQuery获取所有图片的位置信息 + const query = wx.createSelectorQuery() + query.selectAll('.detail-rich-text-wrapper >>> img').boundingClientRect() + query.exec((res) => { + if (!res || !res[0] || res[0].length === 0) { + console.log('[activity-detail] 未找到图片元素') + return + } + + const imageRects = res[0] + const detailImageUrls = this.data.activity.detailImageUrls || [] + + // 判断点击位置是否在某个图片上 + for (let i = 0; i < imageRects.length; i++) { + const rect = imageRects[i] + if (clientX >= rect.left && clientX <= rect.right && + clientY >= rect.top && clientY <= rect.bottom) { + // 点击了第i张图片 + console.log('[activity-detail] 点击了第', i, '张图片') + + if (detailImageUrls.length > 0) { + wx.previewImage({ + current: detailImageUrls[i], + urls: detailImageUrls + }) + } + break + } + } + }) + }, + + /** + * 查看全部参与者 + */ + onViewAllParticipants() { + this.setData({ showParticipantsModal: true }) + }, + + /** + * 关闭参与者弹窗 + */ + onCloseParticipantsModal() { + this.setData({ showParticipantsModal: false }) + }, + + /** + * 关闭二维码弹窗 + */ + onCloseQrcodeModal() { + this.setData({ showQrcodeModal: false }) + }, + + /** + * 保存二维码 + */ + async onSaveQrcode() { + try { + const qrcodeUrl = this.data.qrcodeImageUrl || this.data.activity.activity_guide_qrcode + if (!qrcodeUrl) { + wx.showToast({ title: '二维码不存在', icon: 'none' }) + return + } + + wx.showLoading({ title: '正在保存...' }) + + const downloadRes = await new Promise((resolve, reject) => { + wx.downloadFile({ + url: qrcodeUrl, + success: resolve, + fail: reject + }) + }) + + if (downloadRes.statusCode !== 200) throw new Error('下载失败') + + await new Promise((resolve, reject) => { + wx.saveImageToPhotosAlbum({ + filePath: downloadRes.tempFilePath, + success: resolve, + fail: reject + }) + }) + + wx.hideLoading() + wx.showToast({ title: '保存成功', icon: 'success' }) + this.onCloseQrcodeModal() + } catch (err) { + wx.hideLoading() + console.error('保存失败', err) + wx.showToast({ title: '保存失败', icon: 'none' }) + } + }, + + /** + * 阻止冒泡 + */ + preventBubble() { + return + }, + + /** + * 点击推荐活动 + */ + onRecommendTap(e) { + const id = e.currentTarget.dataset.id + wx.redirectTo({ + url: `/pages/activity-detail/activity-detail?id=${id}` + }) + }, + + /** + * 收藏/取消收藏 + */ + async onToggleFavorite() { + if (!app.globalData.isLoggedIn) { + wx.navigateTo({ + url: '/pages/login/login' + }) + return + } + + const { activity } = this.data + const isFavorited = activity.is_favorited + + try { + if (isFavorited) { + // 取消收藏 + await api.activity.unfavorite(activity.id) + wx.showToast({ + title: '已取消收藏', + icon: 'success' + }) + } else { + // 收藏 + await api.activity.favorite(activity.id) + wx.showToast({ + title: '收藏成功', + icon: 'success' + }) + } + + // 更新状态 + this.setData({ + 'activity.is_favorited': !isFavorited + }) + } catch (err) { + wx.showToast({ + title: err.error || err.message || '操作失败', + icon: 'none' + }) + } + }, + + /** + * 报名活动 + */ + async onSignUp() { + const { activity } = this.data + + // 检查活动状态 + if (activity.status === 'ended' || activity.status === 'full') { + // 如果活动已结束或已满员,弹出二维码引导进群 + const qrCode = activity.activity_guide_qrcode || activity.activityGuideQrcode || this.data.qrcodeImageUrl || 'https://ai-c.maimanji.com/api/common/qrcode?type=group' + + this.setData({ + qrcodeImageUrl: qrCode, + showQrcodeModal: true + }) + return + } + + // 检查登录 + if (!app.globalData.isLoggedIn) { + wx.navigateTo({ + url: '/pages/login/login' + }) + return + } + + // 显示报名确认 + wx.showModal({ + title: '确认报名', + content: activity.is_free + ? '确定要报名参加这个活动吗?' + : `确定要报名参加这个活动吗?需支付 ¥${activity.price}`, + success: (res) => { + if (res.confirm) { + this.handleSignUp() + } + } + }) + }, + + /** + * 处理报名 + */ + async handleSignUp() { + try { + wx.showLoading({ title: '报名中...' }) + + const userInfo = app.globalData.userInfo || {} + const res = await api.activity.signup(this.data.activityId, { + real_name: userInfo.nickname || '', + phone: userInfo.phone || '', + notes: '' + }) + + wx.hideLoading() + + if (res.success) { + wx.showToast({ + title: '报名成功', + icon: 'success' + }) + + // 刷新数据 + this.loadActivityDetail() + this.loadParticipants() + } else { + // 检查是否需要显示二维码(后端开关关闭、活动已结束或已满员) + if (res.code === 'QR_CODE_REQUIRED' || res.error === 'QR_CODE_REQUIRED' || res.code === 'ACTIVITY_ENDED' || res.error === '活动已结束' || res.code === 'ACTIVITY_FULL' || res.error === '活动已满员') { + const { activity } = this.data + if (activity.activity_guide_qrcode || activity.activityGuideQrcode) { + this.setData({ + qrcodeImageUrl: activity.activity_guide_qrcode || activity.activityGuideQrcode, + showQrcodeModal: true + }) + } + if (res.code === 'ACTIVITY_ENDED' || res.error === '活动已结束' || res.code === 'ACTIVITY_FULL' || res.error === '活动已满员') { + const tip = (res.code === 'ACTIVITY_FULL' || res.error === '活动已满员') ? '活动已满员,进群查看更多' : '活动已结束,进群查看更多' + wx.showToast({ title: tip, icon: 'none' }) + } + } else { + wx.showToast({ + title: res.error || '报名失败', + icon: 'none' + }) + } + } + } catch (err) { + wx.hideLoading() + console.error('报名失败', err) + + // 捕获特定错误码以显示二维码 + const isQrRequired = err && (err.code === 'QR_CODE_REQUIRED' || (err.data && err.data.code === 'QR_CODE_REQUIRED')) + const isActivityEnded = err && (err.code === 'ACTIVITY_ENDED' || (err.data && err.data.code === 'ACTIVITY_ENDED') || err.error === '活动已结束') + const isActivityFull = err && (err.code === 'ACTIVITY_FULL' || (err.data && err.data.code === 'ACTIVITY_FULL') || err.error === '活动已满员') + + if (isQrRequired || isActivityEnded || isActivityFull) { + const { activity } = this.data + if (activity.activity_guide_qrcode || activity.activityGuideQrcode) { + this.setData({ + qrcodeImageUrl: activity.activity_guide_qrcode || activity.activityGuideQrcode, + showQrcodeModal: true + }) + } + if (isActivityEnded || isActivityFull) { + const tip = isActivityFull ? '活动已满员,进群查看更多' : '活动已结束,进群查看更多' + wx.showToast({ title: tip, icon: 'none' }) + } + } else { + wx.showToast({ + title: err.error || err.message || '报名失败', + icon: 'none' + }) + } + } + } +}) diff --git a/pages/activity-detail/activity-detail.json b/pages/activity-detail/activity-detail.json new file mode 100644 index 0000000..59832dc --- /dev/null +++ b/pages/activity-detail/activity-detail.json @@ -0,0 +1,9 @@ +{ + "navigationStyle": "custom", + "enablePullDownRefresh": false, + "backgroundTextStyle": "dark", + "backgroundColor": "#F8F8F8", + "usingComponents": { + "app-icon": "../../components/icon/icon" + } +} diff --git a/pages/activity-detail/activity-detail.wxml b/pages/activity-detail/activity-detail.wxml new file mode 100644 index 0000000..c6a180d --- /dev/null +++ b/pages/activity-detail/activity-detail.wxml @@ -0,0 +1,229 @@ + + + + + + + + + + + + + 活动详情 + + + + + + + + + + + + + + + {{statusText}} + + + + + + {{activity.title}} + + + + + 免费活动 + + + ¥ + {{activity.price}} + /人 + + + + {{activity.participants_count}}人已报名 + + + + + + + + + + 活动时间 + {{activity.start_date}} {{activity.start_time}} + + + + + + + + 活动地点 + {{activity.address}} + + + + + + + + 主办方 + {{activity.organizer}} + + + + + + + + 联系电话 + {{activity.contact_phone}} + + + + + + + + 活动详情 + + + + + + + + + + + {{activity.description}} + + + + + + + + + + + + 参与者 ({{participants.length}}) + + 查看全部 + + + + + + + + + {{item.name}} + {{item.join_time}} + + + + + + + + 相关活动推荐 + + + + + {{item.title}} + {{item.start_date}} + + + + + + + + + + + + + + + + {{activity.is_favorited ? '已收藏' : '收藏'}} + + + + + + + + + + + + + + + + + + 参与者列表 + + + + + + + + + + + {{item.name}} + {{item.join_time}} + + + + + + + + + + + + + + + 加入活动群 + 进群获取更多活动资讯,结交志同道合的朋友 + + + + 长按二维码识别或保存 + 保存二维码 + + diff --git a/pages/activity-detail/activity-detail.wxss b/pages/activity-detail/activity-detail.wxss new file mode 100644 index 0000000..e5ca36b --- /dev/null +++ b/pages/activity-detail/activity-detail.wxss @@ -0,0 +1,815 @@ +/* 活动详情页面样式 - 玫瑰紫版 v3.0 */ +page { + background: linear-gradient(180deg, #E8C3D4 0%, #F5E6ED 100%); +} + +.page-container { + min-height: 100vh; + background: linear-gradient(180deg, #E8C3D4 0%, #F5E6ED 100%); + padding-bottom: 180rpx; +} + +/* 固定导航栏容器 */ +.nav-container { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 999; + background: rgba(248, 249, 252, 0.75); + backdrop-filter: blur(20rpx) saturate(180%); + -webkit-backdrop-filter: blur(20rpx) saturate(180%); +} + +/* 状态栏 */ +.status-bar { + background: transparent; +} + +/* 导航栏 */ +.nav-bar { + display: flex; + align-items: center; + justify-content: center; + padding: 0 32rpx; + background: transparent; + position: relative; +} + +.nav-back { + position: absolute; + left: 32rpx; + top: 50%; + transform: translateY(-50%); + width: 80rpx; + height: 80rpx; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +.back-icon { + width: 48rpx; + height: 48rpx; +} + +.nav-title { + font-size: 36rpx; + font-weight: 700; + color: #1A1A1A; + line-height: 1; +} + +.nav-share { + position: absolute; + right: 32rpx; + top: 50%; + transform: translateY(-50%); + width: 80rpx; + height: 80rpx; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; + background: rgba(255, 255, 255, 0.9); + border-radius: 50%; +} + +.share-icon { + width: 48rpx; + height: 48rpx; + opacity: 0.9; +} + +/* 内容滚动区域 */ +.content-scroll { + height: 100vh; + box-sizing: border-box; +} + +/* 活动封面 - 梦幻渐变占位 */ +.cover-section { + width: 100%; + height: 560rpx; + position: relative; + overflow: hidden; + background: linear-gradient(135deg, #F5E6ED 0%, #FAF5F8 100%); +} + +.cover-image { + width: 100%; + height: 100%; +} + +.status-badge { + position: absolute; + top: 32rpx; + right: 32rpx; + padding: 0 32rpx; + height: 72rpx; + display: flex; + align-items: center; + justify-content: center; + border-radius: 100rpx; + backdrop-filter: blur(10px); +} + +.status-badge.upcoming { + background: linear-gradient(135deg, #60A5FA 0%, #3B82F6 100%); + box-shadow: 0 4rpx 16rpx rgba(96, 165, 250, 0.35); +} + +.status-badge.ongoing { + background: linear-gradient(135deg, #4ADE80 0%, #16A34A 100%); + box-shadow: 0 4rpx 16rpx rgba(74, 222, 128, 0.35); +} + +.status-badge.ended { + background: linear-gradient(135deg, #9CA3AF 0%, #6B7280 100%); + box-shadow: 0 4rpx 16rpx rgba(156, 163, 175, 0.25); +} + +.status-badge.full { + background: linear-gradient(135deg, #F97316 0%, #EA580C 100%); + box-shadow: 0 4rpx 16rpx rgba(249, 115, 22, 0.35); +} + +.status-text { + font-size: 32rpx; + font-weight: 700; + color: #fff; +} + +/* 基本信息区域 - 毛玻璃卡片 */ +.info-section { + margin: 32rpx; + padding: 48rpx; + background: rgba(255, 255, 255, 0.85); + backdrop-filter: blur(16rpx) saturate(150%); + border-radius: 48rpx; + border: 2rpx solid rgba(145, 69, 132, 0.2); + box-shadow: 0 4rpx 20rpx rgba(145, 69, 132, 0.12), + 0 2rpx 8rpx rgba(145, 69, 132, 0.08); +} + +.activity-title { + font-size: 56rpx; + font-weight: 700; + color: #1A1A1A; + line-height: 1.3; + margin-bottom: 32rpx; +} + +/* 价格信息 */ +.price-info { + display: flex; + align-items: center; + justify-content: space-between; + padding: 32rpx 0; + border-bottom: 2rpx solid rgba(145, 69, 132, 0.1); + margin-bottom: 32rpx; +} + +.price-main { + display: flex; + align-items: baseline; + gap: 8rpx; +} + +.price-label { + font-size: 48rpx; + font-weight: 700; + color: #4ADE80; +} + +.price-symbol { + font-size: 40rpx; + font-weight: 700; + color: #F97316; +} + +.price-value { + font-size: 64rpx; + font-weight: 700; + color: #F97316; + line-height: 1; +} + +.price-unit { + font-size: 36rpx; + font-weight: 400; + color: #F97316; +} + +.participants-info { + display: flex; + align-items: center; + gap: 12rpx; + padding: 0 24rpx; + height: 72rpx; + background: rgba(145, 69, 132, 0.1); + border-radius: 100rpx; +} + +.participants-icon { + width: 40rpx; + height: 40rpx; +} + +.participants-text { + font-size: 32rpx; + font-weight: 400; + color: #914584; +} + +/* 信息列表 */ +.info-list { + display: flex; + flex-direction: column; + gap: 32rpx; +} + +.info-item { + display: flex; + align-items: flex-start; + gap: 24rpx; +} + +.info-icon { + width: 48rpx; + height: 48rpx; + margin-top: 4rpx; + flex-shrink: 0; +} + +.info-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 8rpx; +} + +.info-label { + font-size: 32rpx; + font-weight: 400; + color: #6A7282; +} + +.info-value { + font-size: 36rpx; + font-weight: 400; + color: #1A1A1A; + line-height: 1.5; +} + +.phone-link { + color: #914584; + text-decoration: underline; +} + +/* 活动详情区域 - 毛玻璃卡片 */ +.detail-section { + margin: 32rpx; + padding: 48rpx; + background: rgba(255, 255, 255, 0.85); + backdrop-filter: blur(16rpx) saturate(150%); + border-radius: 48rpx; + border: 2rpx solid rgba(145, 69, 132, 0.2); + box-shadow: 0 4rpx 20rpx rgba(145, 69, 132, 0.12), + 0 2rpx 8rpx rgba(145, 69, 132, 0.08); +} + +.section-title { + font-size: 48rpx; + font-weight: 700; + color: #1A1A1A; + margin-bottom: 32rpx; +} + +.detail-content { + margin-bottom: 32rpx; +} + +.detail-text { + font-size: 36rpx; + font-weight: 400; + color: #4A5565; + line-height: 1.8; + white-space: pre-wrap; +} + +/* rich-text 样式 */ +.detail-rich-text-wrapper { + position: relative; +} + +.detail-rich-text { + font-size: 36rpx; + line-height: 1.8; + color: #4A5565; + word-break: break-all; + overflow: hidden; +} + +.detail-images { + display: flex; + flex-direction: column; + gap: 24rpx; + overflow: hidden; +} + +.detail-image { + width: 100%; + max-width: 100%; + border-radius: 24rpx; + display: block; +} + +/* 参与者区域 - 毛玻璃卡片 */ +.participants-section { + margin: 32rpx; + padding: 48rpx; + background: rgba(255, 255, 255, 0.85); + backdrop-filter: blur(16rpx) saturate(150%); + border-radius: 48rpx; + border: 2rpx solid rgba(145, 69, 132, 0.2); + box-shadow: 0 4rpx 20rpx rgba(145, 69, 132, 0.12), + 0 2rpx 8rpx rgba(145, 69, 132, 0.08); +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 32rpx; +} + +.view-all { + display: flex; + align-items: center; + gap: 8rpx; +} + +.view-all-text { + font-size: 32rpx; + font-weight: 400; + color: #914584; +} + +.view-all-icon { + width: 32rpx; + height: 32rpx; +} + +.participants-list { + display: flex; + flex-direction: column; + gap: 24rpx; +} + +.participant-item { + display: flex; + align-items: center; + gap: 24rpx; +} + +.participant-avatar { + width: 96rpx; + height: 96rpx; + border-radius: 50%; + border: 2rpx solid rgba(145, 69, 132, 0.2); + background: linear-gradient(135deg, #F5E6ED 0%, #FAF5F8 100%); +} + +.participant-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 8rpx; +} + +.participant-name { + font-size: 36rpx; + font-weight: 500; + color: #1A1A1A; +} + +.participant-time { + font-size: 28rpx; + font-weight: 400; + color: #6A7282; +} + +/* 推荐活动区域 - 毛玻璃卡片 */ +.recommend-section { + margin: 32rpx; + padding: 48rpx; + background: rgba(255, 255, 255, 0.85); + backdrop-filter: blur(16rpx) saturate(150%); + border-radius: 48rpx; + border: 2rpx solid rgba(145, 69, 132, 0.2); + box-shadow: 0 4rpx 20rpx rgba(145, 69, 132, 0.12), + 0 2rpx 8rpx rgba(145, 69, 132, 0.08); +} + +.recommend-list { + display: flex; + flex-wrap: wrap; + gap: 20rpx; + margin-top: 24rpx; +} + +.recommend-item { + width: calc((100% - 20rpx) / 2); + background: rgba(245, 230, 237, 0.6); + backdrop-filter: blur(8rpx); + border-radius: 24rpx; + overflow: hidden; + border: 2rpx solid rgba(145, 69, 132, 0.15); + box-sizing: border-box; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; +} + +.recommend-item:active { + transform: translateY(-2rpx) scale(0.98); + box-shadow: 0 2rpx 12rpx rgba(145, 69, 132, 0.15); +} + +.recommend-image { + width: 100%; + height: 180rpx; + background: linear-gradient(135deg, #F5E6ED 0%, #FAF5F8 100%); + display: block; +} + +.recommend-info { + padding: 16rpx; + display: flex; + flex-direction: column; + gap: 8rpx; + background: rgba(255, 255, 255, 0.9); +} + +.recommend-title { + font-size: 26rpx; + font-weight: 500; + color: #1A1A1A; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + line-height: 1.4; + min-height: 72rpx; + word-break: break-all; +} + +.recommend-date { + font-size: 22rpx; + font-weight: 400; + color: #6A7282; + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* 底部占位 */ +.bottom-placeholder { + height: 200rpx; +} + +/* 底部操作栏 */ +.bottom-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 198rpx; + background: #fff; + display: flex; + align-items: center; + padding: 0 32rpx; + padding-bottom: env(safe-area-inset-bottom); + box-shadow: 0 -8rpx 24rpx rgba(0, 0, 0, 0.04); + z-index: 100; + box-sizing: border-box; +} + +.bar-left { + display: flex; + align-items: center; + gap: 12rpx; +} + +.action-btn { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: transparent; + border: none; + padding: 0; + width: 80rpx; +} + +.share-btn::after { + border: none; +} + +.action-icon { + width: 48rpx; + height: 48rpx; +} + +.action-text { + font-size: 22rpx; + font-weight: 400; + color: #64748b; + margin-top: 4rpx; +} + +.bar-right { + flex: 1; + margin-left: 0rpx; +} + +.signup-btn { + width: 100%; + height: 96rpx; + display: flex; + align-items: center; + justify-content: center; + border-radius: 100rpx; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; +} + +.signup-btn:active { + transform: scale(0.96); +} + +.signup-btn.upcoming { + background: linear-gradient(135deg, #914584 0%, #7A3A6F 100%); + box-shadow: 0 6rpx 24rpx rgba(145, 69, 132, 0.4), + 0 3rpx 12rpx rgba(145, 69, 132, 0.3); +} + +.signup-btn.upcoming:active { + box-shadow: 0 4rpx 16rpx rgba(145, 69, 132, 0.45); +} + +.signup-btn.ongoing { + background: linear-gradient(135deg, #4ADE80 0%, #16A34A 100%); + box-shadow: 0 6rpx 24rpx rgba(74, 222, 128, 0.35); +} + +.signup-btn.ongoing:active { + box-shadow: 0 4rpx 16rpx rgba(74, 222, 128, 0.4); +} + +.signup-btn.ended { + background: linear-gradient(135deg, #9CA3AF 0%, #6B7280 100%); + box-shadow: 0 4rpx 16rpx rgba(156, 163, 175, 0.25); +} + +.signup-btn.full { + background: linear-gradient(135deg, #F97316 0%, #EA580C 100%); + box-shadow: 0 6rpx 24rpx rgba(249, 115, 22, 0.35); +} + +.signup-btn.full:active { + box-shadow: 0 4rpx 16rpx rgba(249, 115, 22, 0.4); +} + +.signup-text { + font-size: 32rpx; + font-weight: 700; + color: #fff; + white-space: nowrap; +} + +/* 参与者列表弹窗 */ +.participants-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(26, 26, 26, 0.5); + z-index: 1000; + display: flex; + align-items: flex-end; + visibility: hidden; + transition: all 0.3s ease; +} + +.participants-modal.show { + visibility: visible; +} + +.participants-modal .modal-content { + width: 100%; + background: #fff; + border-radius: 32rpx 32rpx 0 0; + padding: 40rpx 32rpx; + transform: translateY(100%); + transition: all 0.3s ease; +} + +.participants-modal.show .modal-content { + transform: translateY(0); +} + +/* 二维码引导弹窗 */ +.qrcode-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + visibility: hidden; + opacity: 0; + transition: all 0.3s ease; +} + +.qrcode-modal.show { + visibility: visible; + opacity: 1; +} + +.qrcode-modal .modal-mask { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4rpx); +} + +.qrcode-modal .modal-content { + position: relative; + width: 600rpx; + background: #FFFFFF; + border-radius: 48rpx; + padding: 60rpx 40rpx; + display: flex; + flex-direction: column; + align-items: center; + z-index: 1; + transform: scale(0.8); + transition: all 0.3s ease; +} + +.qrcode-modal.show .modal-content { + transform: scale(1); +} + +.qrcode-modal .close-btn { + position: absolute; + top: 30rpx; + right: 30rpx; + width: 60rpx; + height: 60rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.qrcode-modal .close-icon { + width: 32rpx; + height: 32rpx; +} + +.qrcode-modal .modal-title { + font-size: 40rpx; + font-weight: bold; + color: #333; + margin-bottom: 12rpx; +} + +.qrcode-modal .modal-subtitle { + font-size: 28rpx; + color: #666; + margin-bottom: 40rpx; +} + +.qrcode-modal .qrcode-container { + width: 400rpx; + height: 400rpx; + background: #f9f9f9; + border: 2rpx solid #eee; + border-radius: 24rpx; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 24rpx; + overflow: hidden; +} + +.qrcode-modal .qrcode-image { + width: 360rpx; + height: 360rpx; +} + +.qrcode-modal .modal-tips { + font-size: 24rpx; + color: #999; + margin-bottom: 40rpx; +} + +.qrcode-modal .save-btn { + width: 100%; + height: 88rpx; + background: #07C160; + color: #fff; + border-radius: 44rpx; + display: flex; + align-items: center; + justify-content: center; + font-size: 32rpx; + font-weight: bold; +} + +.qrcode-modal .save-btn:active { + opacity: 0.8; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 48rpx; + border-bottom: 2rpx solid rgba(145, 69, 132, 0.1); +} + +.modal-title { + font-size: 48rpx; + font-weight: 700; + color: #1A1A1A; +} + +.modal-close { + width: 80rpx; + height: 80rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.close-icon { + width: 48rpx; + height: 48rpx; +} + +.modal-scroll { + flex: 1; + overflow-y: auto; +} + +.modal-participants-list { + padding: 32rpx 48rpx; + display: flex; + flex-direction: column; + gap: 32rpx; +} + +.modal-participant-item { + display: flex; + align-items: center; + gap: 24rpx; +} + +.modal-participant-avatar { + width: 112rpx; + height: 112rpx; + border-radius: 50%; + border: 2rpx solid rgba(145, 69, 132, 0.2); + background: linear-gradient(135deg, #F5E6ED 0%, #FAF5F8 100%); +} + +.modal-participant-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 12rpx; +} + +.modal-participant-name { + font-size: 40rpx; + font-weight: 500; + color: #1A1A1A; +} + +.modal-participant-time { + font-size: 32rpx; + font-weight: 400; + color: #6A7282; +} diff --git a/pages/agreement/agreement.js b/pages/agreement/agreement.js new file mode 100644 index 0000000..367e5d0 --- /dev/null +++ b/pages/agreement/agreement.js @@ -0,0 +1,76 @@ +/** + * 协议页面 + * 显示用户服务协议或隐私协议 + */ +Page({ + data: { + statusBarHeight: 20, + title: '', + content: '' + }, + + onLoad(options) { + const code = options.code || 'user_service' + + // 获取状态栏高度 + const systemInfo = wx.getSystemInfoSync() + this.setData({ + statusBarHeight: systemInfo.statusBarHeight || 20 + }) + + this.loadAgreement(code); + }, + + loadAgreement(code) { + wx.showLoading({ title: '加载中...' }); + + // 优先读取本地缓存 + const cached = wx.getStorageSync(`agreement_${code}`); + const CACHE_DURATION = 60 * 60 * 1000; // 1小时 + const now = Date.now(); + + if (cached && cached.timestamp && (now - cached.timestamp < CACHE_DURATION)) { + this.setData({ + title: cached.data.title, + content: cached.data.content + }); + wx.hideLoading(); + return; + } + + // 网络请求 + wx.request({ + url: `https://ai-c.maimanji.com/api/agreements?code=${code}`, + method: 'GET', + success: (res) => { + wx.hideLoading(); + if (res.data.success) { + const data = res.data.data; + this.setData({ + title: data.title, + content: data.content + }); + + // 写入缓存 + wx.setStorageSync(`agreement_${code}`, { + data: data, + timestamp: now + }); + } else { + wx.showToast({ title: '协议不存在', icon: 'none' }); + } + }, + fail: (err) => { + wx.hideLoading(); + wx.showToast({ title: '加载失败', icon: 'none' }); + } + }); + }, + + /** + * 返回上一页 + */ + goBack() { + wx.navigateBack() + } +}) diff --git a/pages/agreement/agreement.json b/pages/agreement/agreement.json new file mode 100644 index 0000000..e90e996 --- /dev/null +++ b/pages/agreement/agreement.json @@ -0,0 +1,4 @@ +{ + "usingComponents": {}, + "navigationStyle": "custom" +} diff --git a/pages/agreement/agreement.wxml b/pages/agreement/agreement.wxml new file mode 100644 index 0000000..c639776 --- /dev/null +++ b/pages/agreement/agreement.wxml @@ -0,0 +1,20 @@ + + + + + + + + + {{title}} + + + + + + + + + + + diff --git a/pages/agreement/agreement.wxss b/pages/agreement/agreement.wxss new file mode 100644 index 0000000..3ab1c5f --- /dev/null +++ b/pages/agreement/agreement.wxss @@ -0,0 +1,64 @@ +/* 协议页面样式 */ +.agreement-page { + min-height: 100vh; + background: #fff; +} + +/* 导航栏 */ +.nav-bar { + position: fixed; + top: 0; + left: 0; + right: 0; + background: #fff; + z-index: 100; + border-bottom: 1rpx solid #f0f0f0; +} + +.nav-content { + height: 88rpx; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 32rpx; +} + +.back-btn { + width: 72rpx; + height: 72rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.back-icon { + width: 40rpx; + height: 40rpx; +} + +.nav-title { + font-size: 36rpx; + font-weight: bold; + color: #1e2939; +} + +.nav-placeholder { + width: 72rpx; +} + +/* 内容区域 */ +.content-scroll { + height: 100vh; +} + +.content-wrapper { + padding: 32rpx; + padding-bottom: 100rpx; +} + +.content-text { + font-size: 28rpx; + color: #4a5565; + line-height: 1.8; + white-space: pre-wrap; +} diff --git a/pages/backpack/backpack.js b/pages/backpack/backpack.js new file mode 100644 index 0000000..eb35140 --- /dev/null +++ b/pages/backpack/backpack.js @@ -0,0 +1,54 @@ +const { request } = require('../../utils_new/request'); + +Page({ + data: { + statusBarHeight: 20, + navBarHeight: 44, + totalNavHeight: 64, + loading: true, + items: [] + }, + onLoad() { + const sys = wx.getSystemInfoSync(); + const menu = wx.getMenuButtonBoundingClientRect(); + const statusBarHeight = sys.statusBarHeight || 20; + const navBarHeight = menu.height + (menu.top - statusBarHeight) * 2; + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight: statusBarHeight + navBarHeight + }); + this.load(); + }, + onBack() { + wx.navigateBack({ delta: 1 }); + }, + async load() { + this.setData({ loading: true }); + try { + try { + const res = await request({ url: '/api/backpack', method: 'GET' }); + const body = res.data || {}; + if (body.code !== 0) throw new Error(body.message || '加载失败'); + const items = (body.data?.items || body.data || []).map((x) => ({ + id: x.id || x.item_id || '', + name: x.name || x.item_name || '', + quantity: Number(x.quantity || 0), + image_url: x.image_url || x.imageUrl || '' + })); + this.setData({ items }); + } catch (err) { + console.log('API failed, using mock data'); + this.setData({ + items: [ + { id: 1, name: '新手礼包', quantity: 1, image_url: '' }, + { id: 2, name: '加速卡', quantity: 5, image_url: '' } + ] + }); + } + } finally { + this.setData({ loading: false }); + } + } +}); + diff --git a/pages/backpack/backpack.json b/pages/backpack/backpack.json new file mode 100644 index 0000000..3153ca5 --- /dev/null +++ b/pages/backpack/backpack.json @@ -0,0 +1,5 @@ +{ + "usingComponents": { + "app-icon": "/components/icon/icon" + } +} diff --git a/pages/backpack/backpack.wxml b/pages/backpack/backpack.wxml new file mode 100644 index 0000000..f2ff0d5 --- /dev/null +++ b/pages/backpack/backpack.wxml @@ -0,0 +1,31 @@ + + + + + + + + 背包物品 + + + + + + 加载中... + 暂无物品 + + + + + + + + + {{item.name}} + x{{item.quantity}} + + + + + + diff --git a/pages/backpack/backpack.wxss b/pages/backpack/backpack.wxss new file mode 100644 index 0000000..c5113b3 --- /dev/null +++ b/pages/backpack/backpack.wxss @@ -0,0 +1,121 @@ +.page { + min-height: 100vh; + background: #E8C3D4; +} + +.nav-container { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 999; + background: rgba(248, 249, 252, 0.75); + backdrop-filter: blur(20rpx) saturate(180%); + -webkit-backdrop-filter: blur(20rpx) saturate(180%); +} + +.status-bar { + background: transparent; +} + +.nav-bar { + display: flex; + align-items: center; + justify-content: center; + padding: 0 32rpx; + position: relative; +} + +.nav-back { + position: absolute; + left: 32rpx; + top: 50%; + transform: translateY(-50%); + width: 80rpx; + height: 80rpx; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +.back-icon { + width: 48rpx; + height: 48rpx; +} + +.nav-title { + font-size: 34rpx; + font-weight: 700; + color: #1A1A1A; +} + +.wrap { + padding: 24rpx; +} + +.card { + background: #ffffff; + border-radius: 40rpx; + padding: 24rpx; + box-shadow: 0 10rpx 20rpx rgba(17, 24, 39, 0.04); +} + +.loading, +.empty { + text-align: center; + color: #9ca3af; + font-weight: 800; + padding: 80rpx 0; +} + +.grid { + display: flex; + flex-wrap: wrap; + gap: 16rpx; +} + +.item { + width: calc(33.333% - 11rpx); + border: 2rpx solid #f3f4f6; + border-radius: 24rpx; + padding: 16rpx; +} + +.thumb { + height: 160rpx; + border-radius: 18rpx; + overflow: hidden; + background: #f9fafb; + display: flex; + align-items: center; + justify-content: center; +} + +.img { + width: 100%; + height: 100%; +} + +.img-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.name { + margin-top: 12rpx; + font-size: 24rpx; + font-weight: 900; + color: #111827; +} + +.count { + margin-top: 6rpx; + font-size: 22rpx; + color: #6b7280; + font-weight: 700; +} + diff --git a/pages/brand/brand.js b/pages/brand/brand.js new file mode 100644 index 0000000..b4eeb00 --- /dev/null +++ b/pages/brand/brand.js @@ -0,0 +1,51 @@ +const api = require('../../utils/api') + +Page({ + data: { + sections: [], + loading: true, + error: null + }, + + onLoad() { + this.loadBrandInfo() + }, + + onBack() { + wx.navigateBack() + }, + + async loadBrandInfo() { + this.setData({ loading: true, error: null }) + + try { + const res = await api.common.getBrandConfig() + console.log('[brand] 品牌配置响应:', res) + + if (res.success && res.data) { + const data = res.data + const sections = [] + + if (data.about_brand && data.about_brand.value) { + sections.push({ title: '关于品牌', content: data.about_brand.value }) + } + if (data.company_intro && data.company_intro.value) { + sections.push({ title: '公司简介', content: data.company_intro.value }) + } + if (data.contact_info && data.contact_info.value) { + sections.push({ title: '联系我们', content: data.contact_info.value }) + } + + this.setData({ sections }) + console.log('[brand] 品牌信息sections:', this.data.sections) + } else { + this.setData({ error: res.error || '加载失败' }) + } + } catch (err) { + console.error('[brand] 加载品牌信息失败:', err) + this.setData({ error: err.message || '加载失败' }) + } finally { + this.setData({ loading: false }) + } + } +}) diff --git a/pages/brand/brand.json b/pages/brand/brand.json new file mode 100644 index 0000000..8835af0 --- /dev/null +++ b/pages/brand/brand.json @@ -0,0 +1,3 @@ +{ + "usingComponents": {} +} \ No newline at end of file diff --git a/pages/brand/brand.wxml b/pages/brand/brand.wxml new file mode 100644 index 0000000..e7f1f55 --- /dev/null +++ b/pages/brand/brand.wxml @@ -0,0 +1,39 @@ + + + + + + 返回 + + 关于品牌 + + + + + + + 加载中... + + + + + {{error}} + 点击重试 + + + + + + + + + + + + + + + 暂无品牌信息 + + + diff --git a/pages/brand/brand.wxss b/pages/brand/brand.wxss new file mode 100644 index 0000000..f20896b --- /dev/null +++ b/pages/brand/brand.wxss @@ -0,0 +1,106 @@ +.page { + min-height: 100vh; + background-color: #f8f8f8; + display: flex; + flex-direction: column; +} + +.unified-header { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 194rpx; + background: linear-gradient(135deg, #B06AB3 0%, #9B59B6 100%); + border-bottom: none; + display: flex; + align-items: flex-end; + justify-content: space-between; + padding: 0 32rpx 20rpx; + z-index: 1000; +} + +.unified-header-left { + display: flex; + align-items: center; + gap: 8rpx; + width: 160rpx; + height: 56rpx; +} + +.unified-back-icon { + width: 56rpx; + height: 56rpx; +} + +.unified-back-text { + font-size: 34rpx; + font-weight: bold; + color: #ffffff; +} + +.unified-header-title { + font-size: 40rpx; + font-weight: bold; + color: #ffffff; + flex: 1; + text-align: center; +} + +.unified-header-right { + width: 160rpx; + height: 56rpx; +} + +.content { + flex: 1; +} + +.loading-tip, .error-tip, .empty-tip { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding-top: 200rpx; +} + +.loading-tip text, .empty-tip text, .error-tip text { + font-size: 28rpx; + color: #999; +} + +.error-tip text:first-child { + color: #ff6b6b; + margin-bottom: 20rpx; +} + +.retry-btn { + background: linear-gradient(135deg, #B06AB3 0%, #9B59B6 100%); + color: #fff !important; + padding: 16rpx 48rpx; + border-radius: 36rpx; + font-size: 28rpx !important; +} + +.empty-tip image { + width: 240rpx; + height: 240rpx; + margin-bottom: 20rpx; +} + +.brand-content { + padding: 30rpx; +} + +.section-item { + background: #fff; + border-radius: 20rpx; + padding: 30rpx; + margin-bottom: 30rpx; +} + +.section-item rich-text { + font-size: 28rpx; + color: #666; + line-height: 1.8; +} diff --git a/pages/character-detail/character-detail.js b/pages/character-detail/character-detail.js new file mode 100644 index 0000000..87d93bf --- /dev/null +++ b/pages/character-detail/character-detail.js @@ -0,0 +1,657 @@ +// pages/character-detail/character-detail.js +// 角色详情页面 - 对接后端API + +const app = getApp() +const api = require('../../utils/api') +const config = require('../../config/index') + +// 获取静态资源基础URL(去掉/api后缀) +const getStaticBaseUrl = () => { + const apiUrl = config.API_BASE_URL + return apiUrl.replace(/\/api$/, '') +} + +Page({ + data: { + loading: true, + character: null, + isLiked: false, + isPlaying: false, // 音频播放状态 + + // 爱心弹窗 + showHeartPopup: false, + userLovePoints: 0, // 用户爱心值 + purchasing: false, + unlockHeartsCost: 500 // 默认解锁爱心成本 + }, + + onLoad(options) { + const characterId = options.id + if (characterId) { + this.loadCharacterDetail(characterId) + this.loadHeartBalance() + this.loadUnlockConfig(characterId) + } else { + wx.showToast({ title: '参数错误', icon: 'none' }) + setTimeout(() => wx.navigateBack(), 1500) + } + }, + + /** + * 加载解锁配置 + */ + async loadUnlockConfig(characterId) { + try { + const res = await api.chat.getQuota(characterId) + if (res.success && res.data && res.data.unlock_config) { + const cost = res.data.unlock_config.hearts_cost + if (typeof cost === 'number') { + this.setData({ + unlockHeartsCost: cost + }) + console.log('[character-detail] 已从后端同步解锁成本:', cost) + } + } + } catch (err) { + console.log('[character-detail] 加载解锁配置失败,使用默认值', err) + } + }, + + onShow() { + // 刷新爱心余额 + this.loadHeartBalance() + }, + + onUnload() { + // 清理音频资源 + if (this.audioContext) { + this.audioContext.stop() + this.audioContext.destroy() + this.audioContext = null + } + }, + + /** + * 加载角色详情 + */ + async loadCharacterDetail(id) { + this.setData({ loading: true }) + + try { + const res = await api.character.getDetail(id) + + console.log('[character-detail] API返回原始数据:', JSON.stringify(res)) + + // 兼容两种返回格式 + let data = null + if (res.code === 0 && res.data) { + data = res.data + } else if (res.success && res.data) { + data = res.data + } + + if (data) { + // 打印关键字段 + console.log('[character-detail] greetingAudioUrl:', data.greetingAudioUrl) + console.log('[character-detail] greeting_audio_url:', data.greeting_audio_url) + console.log('[character-detail] audio_url:', data.audio_url) + + const character = this.transformCharacter(data) + + console.log('[character-detail] 转换后的audioUrl:', character.audioUrl) + + this.setData({ + character, + isLiked: data.is_liked || false, + loading: false + }) + } else { + throw new Error(res.message || '加载失败') + } + } catch (err) { + console.error('加载角色详情失败', err) + this.setData({ loading: false }) + wx.showToast({ title: '加载失败', icon: 'none' }) + } + }, + + /** + * 转换角色数据格式 + */ + transformCharacter(data) { + // 静态资源基础URL + const staticBaseUrl = getStaticBaseUrl() + + console.log('[character-detail transformCharacter] 原始数据:', { + age: data.age, + companion_type: data.companion_type, + name: data.name + }) + + // 转换照片路径为完整URL + const convertPhotoUrl = (url) => { + if (!url) return '' + // 如果已经是完整URL,直接返回 + if (url.startsWith('http://') || url.startsWith('https://')) { + return url + } + // 如果是相对路径,拼接基础URL + if (url.startsWith('/characters/')) { + return staticBaseUrl + url + } + return url + } + + // 使用后端返回的短标签格式爱好,或者自己处理 + let hobbies = data.hobbiesTags || [] + if (hobbies.length === 0 && data.hobbies) { + // 如果后端没有返回hobbiesTags,自己处理 + if (typeof data.hobbies === 'string') { + hobbies = [data.hobbies.substring(0, 4)] + } else if (Array.isArray(data.hobbies)) { + hobbies = data.hobbies.map(h => String(h).substring(0, 4)).slice(0, 5) + } else if (typeof data.hobbies === 'object') { + const allHobbies = data.hobbies.adult || Object.values(data.hobbies).flat() + hobbies = allHobbies.map(h => { + // 提取短标签:去除括号内容,截取前4个字符 + let tag = String(h).replace(/[((][^))]*[))]/g, '').trim() + const parts = tag.split(/[、,,;;::]/) + tag = parts[0].trim() + return tag.length > 4 ? tag.substring(0, 4) : tag + }).filter(t => t.length > 0).slice(0, 5) + } + } + + // 解析性格特点(也提取短标签) + let traits = data.traits || [] + if (!Array.isArray(traits) || traits.length === 0) { + if (data.personalityTraits) { + if (Array.isArray(data.personalityTraits)) { + traits = data.personalityTraits.map(t => String(t).substring(0, 4)).slice(0, 5) + } else if (typeof data.personalityTraits === 'object') { + const allTraits = Object.values(data.personalityTraits).flat() + traits = allTraits.map(t => String(t).substring(0, 4)).slice(0, 5) + } + } + } + + // 相册:优先使用后端返回的gallery,转换为完整URL + let photos = (data.gallery || []).map(convertPhotoUrl).filter(p => p) + if (photos.length === 0) { + // 如果没有相册,使用宣传图或头像作为相册 + const promoImage = convertPhotoUrl(data.promoImage || data.promo_image) + const avatar = convertPhotoUrl(data.avatar || data.image) + if (promoImage) { + photos = [promoImage] + } else if (avatar) { + photos = [avatar] + } + } + + // 头像:转换为完整URL(用于小头像显示) + const avatar = convertPhotoUrl(data.avatar || data.logo || data.image) || '' + + // 宣传图:转换为完整URL(用于头部大图显示) + const promoImage = convertPhotoUrl(data.promoImage || data.promo_image || data.avatar || data.image) || '' + + // 处理年龄显示:如果已包含"岁"则直接使用,否则添加"岁" + let ageDisplay = '' + if (data.age) { + const ageStr = String(data.age).trim() + // 如果age不为空且不是'null'字符串 + if (ageStr && ageStr !== 'null' && ageStr !== 'undefined') { + ageDisplay = ageStr.includes('岁') ? ageStr : ageStr + '岁' + } + } + + // 如果没有年龄,尝试从companion_type中提取 + if (!ageDisplay && data.companion_type) { + const ageMatch = data.companion_type.match(/(\d+岁)/) + if (ageMatch) { + ageDisplay = ageMatch[1] + } + } + + console.log('[character-detail transformCharacter] 年龄处理结果:', { + 原始age: data.age, + 最终ageDisplay: ageDisplay + }) + + return { + id: data.id, + name: data.name, + avatar: avatar, + promoImage: promoImage, // 宣传图(用于头部大图) + job: data.occupation || data.companionType || '', + age: data.age || '', + ageDisplay: ageDisplay, + location: data.location || data.province || '', + audioDuration: data.audio_duration || '12"', + about: data.about || data.selfIntroduction || data.bio || '', + traits: traits.slice(0, 5), + hobbies: hobbies.slice(0, 5), + photos: photos, + voiceId: data.voice_id || data.voiceFeatures, + audioUrl: data.greetingAudioUrl || data.greeting_audio_url || data.audio_url || '', // 预录制的开场白音频URL + // Edge TTS 配置(用于实时生成语音) + edgeTtsVoice: data.edgeTtsVoice || data.edge_tts_voice || '', + edgeTtsRate: data.edgeTtsRate || data.edge_tts_rate || '', + edgeTtsPitch: data.edgeTtsPitch || data.edge_tts_pitch || '' + } + }, + + // 返回上一页 + goBack() { + wx.navigateBack() + }, + + // 播放音频 + async onPlayAudio() { + const { character, isPlaying } = this.data + + // 防止重复点击 + if (isPlaying) { + // 如果正在播放,点击则停止 + if (this.audioContext) { + try { + this.audioContext.stop() + } catch (e) {} + } + this.setData({ isPlaying: false }) + return + } + + // 检查是否有有效的音频URL(非空字符串) + const audioUrl = character.audioUrl + console.log('[character-detail] audioUrl:', audioUrl, '类型:', typeof audioUrl) + + if (audioUrl && audioUrl.trim() !== '') { + // 处理相对路径,拼接完整URL + let fullAudioUrl = audioUrl + if (audioUrl.startsWith('/')) { + fullAudioUrl = getStaticBaseUrl() + audioUrl + } + console.log('[character-detail] 完整音频URL:', fullAudioUrl) + + // 先检查文件是否存在 + wx.request({ + url: fullAudioUrl, + method: 'HEAD', + success: (res) => { + console.log('[character-detail] HEAD请求结果:', res.statusCode) + if (res.statusCode === 200) { + this.playAudioUrl(fullAudioUrl) + } else { + wx.showToast({ title: '音频文件不存在', icon: 'none' }) + } + }, + fail: (err) => { + console.log('[character-detail] HEAD请求失败,尝试直接播放:', err) + // 有些服务器不支持HEAD,直接尝试播放 + this.playAudioUrl(fullAudioUrl) + } + }) + return + } + + // 没有预录制音频,提示用户 + wx.showToast({ + title: '该角色暂无独白音频', + icon: 'none', + duration: 2000 + }) + }, + + // 播放Base64格式的音频 + playBase64Audio(base64Data) { + // 将Base64转换为临时文件 + const fs = wx.getFileSystemManager() + const filePath = `${wx.env.USER_DATA_PATH}/temp_audio_${Date.now()}.mp3` + + try { + // 解码Base64并写入文件 + fs.writeFileSync(filePath, base64Data, 'base64') + + // 播放音频 + this.playAudioUrl(filePath) + } catch (err) { + console.error('写入音频文件失败:', err) + wx.showToast({ title: '播放失败', icon: 'none' }) + } + }, + + // 播放音频URL + playAudioUrl(url) { + console.log('[character-detail] playAudioUrl 开始播放:', url) + + // 如果正在播放,先停止 + if (this.audioContext) { + try { + this.audioContext.stop() + this.audioContext.destroy() + } catch (e) { + console.log('[character-detail] 停止旧音频时出错:', e) + } + this.audioContext = null + } + + // 显示播放中提示 + wx.showToast({ + title: '播放独白中...', + icon: 'none', + duration: 5000 + }) + + // 延迟创建新的音频上下文,避免冲突 + setTimeout(() => { + // 不使用 useWebAudioImplement,某些情况下可能导致问题 + const innerAudioContext = wx.createInnerAudioContext() + this.audioContext = innerAudioContext + + // 设置音量为最大 + innerAudioContext.volume = 1.0 + innerAudioContext.src = url + innerAudioContext.obeyMuteSwitch = false // 不受静音开关影响 + + innerAudioContext.onCanplay(() => { + console.log('[character-detail] 音频可以播放了, duration:', innerAudioContext.duration) + }) + + innerAudioContext.onPlay(() => { + console.log('[character-detail] 音频开始播放, volume:', innerAudioContext.volume) + this.setData({ isPlaying: true }) + }) + + innerAudioContext.onTimeUpdate(() => { + // 每秒打印一次进度,确认音频在播放 + const currentTime = Math.floor(innerAudioContext.currentTime) + if (currentTime !== this._lastLogTime) { + console.log('[character-detail] 播放进度:', currentTime, '/', Math.floor(innerAudioContext.duration || 0)) + this._lastLogTime = currentTime + } + }) + + innerAudioContext.onError((err) => { + console.error('[character-detail] 音频播放错误:', JSON.stringify(err)) + this.setData({ isPlaying: false }) + wx.hideToast() + + // 针对不同错误给出提示 + let errMsg = '播放失败' + if (err.errCode === 10001) { + errMsg = '系统错误,请重试' + } else if (err.errCode === 10002) { + errMsg = '网络错误' + } else if (err.errCode === 10003) { + errMsg = '音频文件错误' + } else if (err.errCode === 10004) { + errMsg = '音频格式不支持' + } else if (err.errMsg && err.errMsg.includes('interruption')) { + errMsg = '播放被中断,请重试' + } + + wx.showToast({ title: errMsg, icon: 'none' }) + }) + + innerAudioContext.onEnded(() => { + console.log('[character-detail] 音频播放结束') + this.setData({ isPlaying: false }) + wx.hideToast() + // 清理临时文件 + if (url.startsWith(wx.env.USER_DATA_PATH)) { + try { + wx.getFileSystemManager().unlinkSync(url) + } catch (e) { + // 忽略删除失败 + } + } + }) + + // 延迟播放,确保音频上下文准备好 + setTimeout(() => { + console.log('[character-detail] 调用 play()') + innerAudioContext.play() + }, 100) + }, 50) + }, + + // 查看全部相册 + onViewAllPhotos() { + const { photos } = this.data.character + if (photos && photos.length > 0) { + wx.previewImage({ + urls: photos, + current: photos[0] + }) + } + }, + + // 预览单张照片 + onPreviewPhoto(e) { + const index = e.currentTarget.dataset.index + const { photos } = this.data.character + if (photos && photos.length > 0) { + wx.previewImage({ + urls: photos, + current: photos[index] + }) + } + }, + + // 不喜欢 - 直接返回上一页 + onDislike() { + wx.navigateBack() + }, + + // 喜欢/取消喜欢 + async onLike() { + const { character, isLiked } = this.data + + // 检查登录 + if (app.checkNeedLogin && app.checkNeedLogin()) return + + try { + const res = await api.character.toggleLike(character.id) + + if (res.success) { + const newLiked = !isLiked + this.setData({ isLiked: newLiked }) + // 静默操作,不显示提示 + } + } catch (err) { + console.error('喜欢操作失败', err) + wx.showToast({ title: '操作失败', icon: 'none' }) + } + }, + + // 聊天 + onChat() { + const { character } = this.data + wx.navigateTo({ + url: `/pages/chat-detail/chat-detail?id=${character.id}&name=${encodeURIComponent(character.name)}` + }) + }, + + // ==================== 爱心弹窗相关 ==================== + + /** + * 加载用户爱心值 + * 使用 /api/auth/me 接口,该接口从 im_users.grass_balance 读取余额 + */ + async loadHeartBalance() { + try { + const token = wx.getStorageSync(config.STORAGE_KEYS.TOKEN) + if (!token) { + this.setData({ userLovePoints: 0 }) + return + } + + const res = await api.auth.getCurrentUser() + if (res.success && res.data) { + this.setData({ + userLovePoints: res.data.grass_balance || 0 + }) + console.log('[character-detail] 爱心值加载成功:', res.data.grass_balance) + } + } catch (err) { + console.log('加载爱心值失败', err) + } + }, + + /** + * 显示爱心弹窗 + */ + showHeartPopup() { + this.setData({ showHeartPopup: true }) + }, + + /** + * 关闭爱心弹窗 + */ + closeHeartPopup() { + this.setData({ showHeartPopup: false }) + }, + + /** + * 阻止事件冒泡 + */ + preventBubble() {}, + + /** + * 阻止滚动穿透 + */ + preventTouchMove() {}, + + /** + * 分享解锁 + */ + onShareUnlock() { + wx.showToast({ + title: '分享功能开发中', + icon: 'none' + }) + // TODO: 实现分享解锁逻辑 + }, + + /** + * 爱心兑换 + */ + async onHeartExchange() { + const { character, userLovePoints, unlockHeartsCost } = this.data + + // 检查登录 + const token = wx.getStorageSync(config.STORAGE_KEYS.TOKEN) + if (!token) { + wx.showToast({ title: '请先登录', icon: 'none' }) + setTimeout(() => { + wx.navigateTo({ url: '/pages/login/login' }) + }, 1500) + return + } + + // 检查爱心值是否足够 + if (userLovePoints < unlockHeartsCost) { + wx.showModal({ + title: '爱心值不足', + content: `您的爱心值不足${unlockHeartsCost},是否前往充值?`, + confirmText: '去充值', + success: (res) => { + if (res.confirm) { + wx.navigateTo({ url: '/pages/recharge/recharge' }) + } + } + }) + return + } + + // 确认兑换 + wx.showModal({ + title: '确认兑换', + content: `使用${unlockHeartsCost}爱心值解锁与${character.name}的聊天?`, + confirmText: '确认兑换', + success: async (res) => { + if (res.confirm) { + await this.doHeartExchange() + } + } + }) + }, + + /** + * 执行爱心兑换 + */ + async doHeartExchange() { + const { character, unlockHeartsCost, userLovePoints } = this.data + + this.setData({ purchasing: true }) + wx.showLoading({ title: '兑换中...' }) + + try { + // 调用角色解锁API + const res = await api.character.unlock({ + character_id: character.id, + unlock_type: 'hearts' + }) + + wx.hideLoading() + + if (res.success) { + wx.showToast({ title: '解锁成功', icon: 'success' }) + this.setData({ showHeartPopup: false }) + + // 更新本地爱心余额(优先使用后端返回,否则本地计算) + const newBalance = res.data?.remaining_hearts ?? (userLovePoints - unlockHeartsCost) + this.setData({ + userLovePoints: newBalance + }) + + // 延迟后跳转到聊天页面 + setTimeout(() => { + wx.navigateTo({ + url: `/pages/chat-detail/chat-detail?id=${character.id}&name=${encodeURIComponent(character.name)}` + }) + }, 1000) + } else { + wx.showToast({ title: res.message || '兑换失败', icon: 'none' }) + } + } catch (err) { + wx.hideLoading() + console.error('兑换失败', err) + wx.showToast({ title: '网络错误,请重试', icon: 'none' }) + } finally { + this.setData({ purchasing: false }) + } + }, + + /** + * 选择爱心套餐(已废弃,保留兼容) + */ + selectHeartPackage(e) { + const index = e.currentTarget.dataset.index + this.setData({ selectedHeartPackage: index }) + }, + + /** + * 购买并解锁角色聊天(已废弃,保留兼容) + * 使用 /api/payment/unified-order 接口 + * 测试模式下返回 testMode: true,订单直接完成,无需调用微信支付 + */ + async buyAndUnlock() { + // 调用新的爱心兑换逻辑 + await this.onHeartExchange() + }, + + /** + * 跳转到用户协议 + */ + goToUserAgreement() { + wx.navigateTo({ url: '/pages/agreement/agreement?code=user-agreement' }) + }, + + /** + * 跳转到隐私政策 + */ + goToPrivacyPolicy() { + wx.navigateTo({ url: '/pages/agreement/agreement?code=privacy-policy' }) + } +}) diff --git a/pages/character-detail/character-detail.json b/pages/character-detail/character-detail.json new file mode 100644 index 0000000..e90e996 --- /dev/null +++ b/pages/character-detail/character-detail.json @@ -0,0 +1,4 @@ +{ + "usingComponents": {}, + "navigationStyle": "custom" +} diff --git a/pages/character-detail/character-detail.wxml b/pages/character-detail/character-detail.wxml new file mode 100644 index 0000000..ec7a779 --- /dev/null +++ b/pages/character-detail/character-detail.wxml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + {{character.name}} + {{character.job}} + + + + + {{character.ageDisplay}} + {{character.location}} + + + + + + + + + {{isPlaying ? '播放独白中...' : '收听独白'}} + + + + + {{character.audioDuration || '12"'}} + + + + + + 相册 + 查看全部 + + + + + + +{{character.photos.length - 2}} + + + + + + + + 关于我 + {{character.about}} + + + + + 兴趣爱好 + + {{item}} + ♥ {{item}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 解锁与{{character.name}}的专属聊天 + + + + + + + + + + + + + + + {{unlockHeartsCost}}爱心 + {{userLovePoints >= unlockHeartsCost ? '余额充足 立即兑换' : '爱心值不足 去充值'}} + + + 兑换 + + + + + + + + + + diff --git a/pages/character-detail/character-detail.wxss b/pages/character-detail/character-detail.wxss new file mode 100644 index 0000000..0a804ec --- /dev/null +++ b/pages/character-detail/character-detail.wxss @@ -0,0 +1,690 @@ +/* 人物详情页样式 */ +.page-container { + min-height: 100vh; + background: #fff; + position: relative; + overflow-x: hidden; +} + +/* 顶部大图区域 */ +.hero-section { + position: relative; + width: 100%; + height: 580rpx; +} + +.hero-image { + width: 100%; + height: 100%; +} + +.hero-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 200rpx; + background: linear-gradient(to bottom, rgba(0,0,0,0.3), transparent); + padding: 96rpx 32rpx 0; +} + +.back-btn { + width: 72rpx; + height: 72rpx; + background: rgba(255,255,255,0.25); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.back-icon { + width: 48rpx; + height: 48rpx; + filter: brightness(0) invert(1); +} + +/* 内容卡片区域 */ +.content-card { + position: relative; + margin-top: -60rpx; + background: #fff; + border-radius: 48rpx 48rpx 0 0; + min-height: calc(100vh - 520rpx); + padding: 36rpx 32rpx; + box-shadow: 0 -10rpx 40rpx rgba(0,0,0,0.08); + height: calc(100vh - 520rpx); + width: 100%; + box-sizing: border-box; + overflow-x: hidden; +} + +/* 基本信息 */ +.profile-header { + margin-bottom: 16rpx; +} + +.profile-name { + display: block; + font-size: 48rpx; + font-weight: 700; + color: #101828; + line-height: 1.3; + margin-bottom: 4rpx; +} + +.profile-job { + display: block; + font-size: 28rpx; + font-weight: 500; + color: #6a7282; +} + +/* 标签 */ +.profile-tags { + display: flex; + gap: 16rpx; + margin-bottom: 24rpx; +} + +.tag { + background: #f3f4f6; + border-radius: 24rpx; + padding: 12rpx 24rpx; + font-size: 26rpx; + font-weight: 600; + color: #364153; +} + +/* 收听独白 */ +.audio-section { + display: flex; + align-items: center; + gap: 24rpx; + background: #f9fafb; + border: 2rpx solid #f3f4f6; + border-radius: 24rpx; + padding: 20rpx 24rpx; + margin-bottom: 28rpx; + transition: all 0.3s ease; +} + +/* 播放中状态 */ +.audio-section.playing { + background: linear-gradient(135deg, #e8f5e9 0%, #f1f8e9 100%); + border-color: #4caf50; + box-shadow: 0 4rpx 12rpx rgba(76, 175, 80, 0.15); +} + +.audio-btn { + width: 80rpx; + height: 80rpx; + background: #fff; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2rpx 6rpx rgba(0,0,0,0.08); + flex-shrink: 0; + transition: all 0.3s ease; +} + +/* 播放中按钮样式 */ +.audio-section.playing .audio-btn { + background: linear-gradient(135deg, #4caf50 0%, #66bb6a 100%); + box-shadow: 0 4rpx 12rpx rgba(76, 175, 80, 0.3); + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { + transform: scale(1); + } + 50% { + transform: scale(1.05); + } +} + +.audio-icon { + width: 40rpx; + height: 40rpx; + opacity: 1; + transition: all 0.3s ease; +} + +/* 播放中图标样式 */ +.audio-section.playing .audio-icon { + filter: brightness(0) invert(1); + animation: rotate 2s linear infinite; +} + +@keyframes rotate { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.audio-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 6rpx; +} + +.audio-label { + font-size: 26rpx; + font-weight: 600; + color: #99a1af; + transition: color 0.3s ease; +} + +/* 播放中文字样式 */ +.audio-section.playing .audio-label { + color: #4caf50; + font-weight: 700; +} + +.audio-wave { + display: flex; + gap: 4rpx; + height: 6rpx; +} + +.wave-bar { + flex: 1; + height: 100%; + background: #d1d5dc; + border-radius: 100rpx; + transition: all 0.3s ease; +} + +/* 播放中波形条动画 */ +.wave-bar.animating { + background: #4caf50; + animation: wave 1s ease-in-out infinite; +} + +.wave-bar.animating:nth-child(2n) { + animation-delay: 0.1s; +} + +.wave-bar.animating:nth-child(3n) { + animation-delay: 0.2s; +} + +.wave-bar.animating:nth-child(4n) { + animation-delay: 0.3s; +} + +@keyframes wave { + 0%, 100% { + height: 6rpx; + opacity: 0.5; + } + 50% { + height: 12rpx; + opacity: 1; + } +} + +.audio-duration { + font-size: 26rpx; + font-weight: 600; + color: #99a1af; + flex-shrink: 0; + transition: color 0.3s ease; +} + +/* 播放中时长样式 */ +.audio-section.playing .audio-duration { + color: #4caf50; +} + +/* 通用区块 */ +.section { + margin-bottom: 24rpx; +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16rpx; +} + +.section-title { + display: block; + font-size: 30rpx; + font-weight: 700; + color: #101828; + margin-bottom: 12rpx; +} + +.section-header .section-title { + margin-bottom: 0; +} + +.section-content { + font-size: 28rpx; + color: #4a5565; + line-height: 1.5; +} + +.view-all { + font-size: 26rpx; + font-weight: 600; + color: #ff6b6b; +} + +/* 兴趣爱好标签 */ +.hobby-tags { + display: flex; + flex-wrap: wrap; + gap: 12rpx; + width: 100%; + box-sizing: border-box; +} + +.hobby-tag { + background: #f9fafb; + border: 2rpx solid #e5e7eb; + border-radius: 24rpx; + padding: 10rpx 24rpx; + font-size: 26rpx; + font-weight: 600; + color: #4a5565; + flex-shrink: 0; +} + +.hobby-tag.highlight { + background: #fff0f0; + border-color: #ff6b6b; + color: #ff6b6b; +} + +/* 相册 */ +.photo-grid { + display: flex; + gap: 16rpx; +} + +.photo-item { + flex: 1; + height: 280rpx; + border-radius: 24rpx; + overflow: hidden; + position: relative; +} + +.photo-image { + width: 100%; + height: 100%; +} + +.photo-overlay { + position: absolute; + inset: 0; + background: rgba(0,0,0,0.2); + display: flex; + align-items: center; + justify-content: center; +} + +.photo-more { + font-size: 32rpx; + font-weight: 600; + color: #fff; +} + +/* 底部占位 */ +.bottom-placeholder { + height: 180rpx; +} + +/* 底部操作按钮 */ +.action-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 220rpx; + background: linear-gradient(to top, #fff 60%, transparent); + display: flex; + align-items: center; + justify-content: center; + gap: 80rpx; + padding-bottom: env(safe-area-inset-bottom); +} + +.action-btn { + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + box-shadow: 0 8rpx 24rpx rgba(0,0,0,0.12); + transition: transform 0.2s ease; +} + +.action-btn:active { + transform: scale(0.95); +} + +/* X按钮 - 不喜欢 */ +.action-btn.dislike-btn { + width: 140rpx; + height: 140rpx; + background: #fff; + border: 3rpx solid #f3f4f6; +} + +.action-btn.dislike-btn .action-btn-icon { + width: 64rpx; + height: 64rpx; + opacity: 0.6; +} + +/* 对话按钮 - 微信绿色系 */ +.action-btn.chat-btn { + width: 140rpx; + height: 140rpx; + background: #07C160; + box-shadow: 0 0 0 6rpx rgba(7, 193, 96, 0.15), 0 12rpx 32rpx rgba(7, 193, 96, 0.35); +} + +.action-btn.chat-btn .action-btn-icon { + width: 64rpx; + height: 64rpx; + filter: brightness(0) invert(1); +} + + +/* ==================== 爱心弹窗样式 ==================== */ + +/* 弹窗遮罩 */ +.heart-popup-mask { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 1001; + display: flex; + align-items: center; + justify-content: center; +} + +/* 弹窗主体 */ +.heart-popup { + width: 620rpx; + background: #F8F9FC; + border-radius: 48rpx; + padding: 0; + position: relative; + animation: heartPopupIn 0.3s ease-out; + box-shadow: 0 50rpx 100rpx -24rpx rgba(0, 0, 0, 0.25); + min-height: 720rpx; +} + +@keyframes heartPopupIn { + from { + opacity: 0; + transform: scale(0.85); + } + to { + opacity: 1; + transform: scale(1); + } +} + +/* 关闭按钮 */ +.heart-popup-close { + position: absolute; + top: 32rpx; + right: 32rpx; + width: 56rpx; + height: 56rpx; + display: flex; + justify-content: center; + align-items: center; + z-index: 10; +} + +.heart-popup-close image { + width: 32rpx; + height: 32rpx; + opacity: 0.7; +} + +/* 弹窗头部 - 白色圆角区域 */ +.heart-popup-header { + background: #FFFFFF; + border-radius: 0 0 60rpx 60rpx; + padding: 64rpx 40rpx 56rpx; + display: flex; + flex-direction: column; + align-items: center; + box-shadow: 0 2rpx 4rpx -2rpx rgba(0, 0, 0, 0.1), 0 2rpx 6rpx 0 rgba(0, 0, 0, 0.1); + position: relative; +} + +.popup-avatar-wrap { + width: 192rpx; + height: 192rpx; + border-radius: 50%; + overflow: hidden; + border: 4rpx solid #FFFFFF; + box-shadow: 0 8rpx 12rpx -8rpx rgba(0, 0, 0, 0.1), 0 20rpx 30rpx -6rpx rgba(0, 0, 0, 0.1); + margin-bottom: 40rpx; + position: relative; +} + +.popup-avatar { + width: 100%; + height: 100%; +} + +/* 头像右下角徽章 */ +.popup-avatar-badge { + position: absolute; + bottom: 0; + right: 0; + width: 56rpx; + height: 56rpx; + background: #FFFFFF; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4rpx 8rpx -4rpx rgba(0, 0, 0, 0.1), 0 8rpx 12rpx -2rpx rgba(0, 0, 0, 0.1); +} + +.popup-avatar-badge image { + width: 32rpx; + height: 32rpx; +} + +.popup-character-name { + font-size: 48rpx; + font-weight: 900; + color: #101828; + text-align: center; + line-height: 1.25; + letter-spacing: -0.025em; +} + +.popup-character-name .highlight { + color: #914584; +} + +/* 选项区域 */ +.heart-popup-options { + padding: 56rpx 40rpx 64rpx; + display: flex; + flex-direction: column; + gap: 32rpx; +} + +/* 选项卡片 */ +.heart-option-card { + background: #FFFFFF; + border: 2rpx solid #F3F4F6; + border-radius: 32rpx; + padding: 0 32rpx; + height: 150rpx; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10rpx; + box-shadow: 0 2rpx 4rpx -2rpx rgba(0, 0, 0, 0.1), 0 2rpx 6rpx 0 rgba(0, 0, 0, 0.1); + transition: all 0.2s ease; +} + +.heart-option-card:active { + transform: scale(0.98); +} + +/* 分享选项 - 粉色背景 */ +.heart-option-card.share-option { + background: #FFF0F5; + border-color: #FCE7F3; +} + +/* 选项左侧内容 */ +.heart-option-left { + display: flex; + align-items: center; + gap: 28rpx; + flex: 1; +} + +.heart-option-icon { + width: 96rpx; + height: 96rpx; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + box-shadow: 0 2rpx 4rpx -2rpx rgba(0, 0, 0, 0.1), 0 2rpx 6rpx 0 rgba(0, 0, 0, 0.1); +} + +/* 分享图标 - 粉色 */ +.heart-option-icon.share-icon { + background: #FFF0F5; +} + +.heart-option-icon.share-icon image { + width: 52rpx; + height: 52rpx; +} + +/* 爱心图标 - 红色 */ +.heart-option-icon.heart-icon { + background: #FFF0F5; +} + +.heart-option-icon.heart-icon image { + width: 52rpx; + height: 52rpx; +} + +.heart-option-info { + display: flex; + flex-direction: column; + gap: 8rpx; + flex: 1; + min-width: 0; +} + +.heart-option-title { + font-size: 36rpx; + font-weight: 900; + line-height: 1.4; + letter-spacing: -0.025em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* 分享选项标题 - 紫色 */ +.share-option .heart-option-title { + color: #914584; +} + +/* 爱心选项标题 - 黑色 */ +.heart-option .heart-option-title { + color: #101828; +} + +.heart-option-desc { + font-size: 28rpx; + font-weight: 700; + line-height: 1.5; + opacity: 0.8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* 分享选项描述 - 紫色 */ +.share-option .heart-option-desc { + color: #914584; +} + +/* 爱心选项描述 - 灰色 */ +.heart-option .heart-option-desc { + color: #6A7282; +} + +/* 选项右侧按钮 */ +.heart-option-btn { + width: 174rpx; + height: 100rpx; + border-radius: 100rpx; + display: flex; + align-items: center; + justify-content: center; + font-size: 40rpx; + font-weight: 900; + letter-spacing: -0.025em; + line-height: 1.5; + flex-shrink: 0; + box-shadow: 0 4rpx 8rpx -4rpx rgba(0, 0, 0, 0.1), 0 8rpx 12rpx -2rpx rgba(0, 0, 0, 0.1); +} + +/* 分享按钮 - 紫色 */ +.heart-option-btn.share-btn { + background: #914584; + color: #FFFFFF; +} + +/* 兑换按钮 - 灰色 */ +.heart-option-btn.exchange-btn { + background: #F3F4F6; + color: #4A5565; +} + +/* 暂不需要按钮 */ +.heart-popup-footer { + padding: 0 40rpx 56rpx; +} + +.heart-cancel-btn { + width: 100%; + height: 102rpx; + background: transparent; + border: none; + display: flex; + align-items: center; + justify-content: center; + font-size: 36rpx; + font-weight: 700; + color: #99A1AF; + letter-spacing: 0.025em; + line-height: 1.5; +} + +.heart-cancel-btn::after { + border: none; +} diff --git a/pages/chat-detail/chat-detail.js b/pages/chat-detail/chat-detail.js new file mode 100644 index 0000000..81f492c --- /dev/null +++ b/pages/chat-detail/chat-detail.js @@ -0,0 +1,2095 @@ +// pages/chat-detail/chat-detail.js +// 聊天详情页面 - 与AI角色聊天 + +const app = getApp() +const api = require('../../utils/api') +const util = require('../../utils/util') +const proactiveMessage = require('../../utils/proactiveMessage') +const imageUrl = require('../../utils/imageUrl') +const config = require('../../config/index') + +// 常用表情 +const EMOJIS = [ + "😊", "😀", "😁", "😃", "😂", "🤣", "😅", "😆", "😉", "😋", "😎", "😍", "😘", "🥰", "😗", "😙", + "🙂", "🤗", "🤩", "🤔", "😐", "😑", "😶", "🙄", "😏", "😣", "😥", "😮", "😯", "😪", "😫", "😴", + "😌", "😛", "😜", "😝", "😒", "😓", "😔", "😕", "🙃", "😲", "😖", "😞", "😟", "😤", "😢", "😭", + "😨", "😩", "😬", "😰", "😱", "😳", "😵", "😡", "😠", "😷", "🤒", "🤕", "😇", "🥳", "🥺", + "👋", "👌", "✌️", "🤞", "👍", "👎", "👏", "🙌", "🤝", "🙏", "💪", "❤️", "🧡", "💛", "💚", "💙", + "💜", "🖤", "💔", "💕", "💖", "💗", "💘", "💝", "🌹", "🌺", "🌻", "🌼", "🌷", "🎉", "🎊", "🎁" +] + +Page({ + data: { + statusBarHeight: 44, + navHeight: 96, + + // 角色信息 + characterId: '', + conversationId: '', + character: { + id: '', + name: '加载中...', + avatar: '', + isOnline: true, + job: '', + location: '', + gender: '', + age: '', + education: '', + serviceCount: 0, + returnCount: 0, + rating: '4.9', + motto: '', + qualification: '', + skills: [], + introduction: '' + }, + + // 用户头像 - 从用户信息获取,使用默认头像 + myAvatar: '/images/default-avatar.svg', + + // 消息列表 + messages: [], + + // 输入状态 + inputText: '', + inputFocus: false, + isVoiceMode: false, + isRecording: false, + showEmoji: false, + recordingDuration: 0, + voiceCancelHint: false, + + // AI状态 + isTyping: false, + isSending: false, + playingVoiceId: null, + + // 滚动控制 + scrollIntoView: '', + scrollTop: 0, // 当前滚动位置 + + // 加载状态 + loading: true, + loadingMore: false, + hasMore: true, + page: 1, + pageSize: 20, // 每页加载20条消息 + isFirstLoad: true, // 是否首次加载 + + // 人物介绍弹窗 + showProfilePopup: false, + + // 查看评价弹窗 + showReviewPopup: false, + reviews: [], + + // 更多功能面板 + showMorePanel: false, + + // 常用语列表 + quickReplies: [ + '你好,很高兴认识你~', + '最近怎么样?', + '有什么想聊的吗?', + '今天心情如何?', + '晚安,好梦~', + '早安,新的一天开始了!' + ], + showQuickReplyPopup: false, + + // 表情列表 + emojis: EMOJIS, + + // 约时间弹窗 + showSchedulePopup: false, + scheduleDate: '', + scheduleTime: '', + + // 礼物相关 + showGiftPopup: false, + giftList: [], + selectedGift: null, + userFlowers: 0, + + // 聊天配额相关 + remainingCount: 10, // 剩余免费次数(已废弃,保留兼容) + maxCount: 10, // 每日最大免费次数(已废弃,保留兼容) + isUnlocked: false, // 是否已解锁该角色 + isVip: false, // 是否为VIP用户 + showUnlockPopup: false, // 显示解锁弹窗 + todayCharacterId: '', // 今天已聊天的角色ID + heartCount: 0, // 用户爱心余额 + unlockHeartsCost: 500, // 默认解锁爱心成本 + + // 免费畅聊相关 + freeTime: null, + countdownText: '' + }, + + onLoad(options) { + const { statusBarHeight, navHeight } = app.globalData + + // 初始化消息处理相关变量 + this.pendingMessages = [] + this.messageTimer = null + this.isProcessing = false + + // 获取参数 + const characterId = options.id || '' + const conversationId = options.conversationId || '' + const characterName = decodeURIComponent(options.name || '') + + // 设置用户头像 + const userInfo = app.globalData.userInfo + const myAvatar = imageUrl.getAvatarUrl(userInfo?.avatar) + + this.setData({ + statusBarHeight, + navHeight, + characterId, + conversationId, + myAvatar, + 'character.name': characterName || '加载中...' + }) + + // 进入聊天详情页时,调用标记已读接口清除未读数 + if (conversationId) { + this.markConversationAsRead(conversationId) + } + + // 加载角色信息和聊天历史 + this.initChat() + }, + + onShow() { + // 每次显示页面时,刷新一次配额状态,确保免费畅聊时间等状态是最新的 + if (!this.data.loading) { + this.loadQuotaStatus() + } + }, + + onUnload() { + // 页面卸载时清理 + // 清除消息处理定时器 + if (this.messageTimer) { + clearTimeout(this.messageTimer) + this.messageTimer = null + } + // 清除图片回复定时器 + if (this.imageReplyTimers && this.imageReplyTimers.length > 0) { + this.imageReplyTimers.forEach(timer => clearTimeout(timer)) + this.imageReplyTimers = [] + } + // 清空待处理消息队列 + this.pendingMessages = [] + this.isProcessing = false + + // 离开页面时标记该角色的主动推送消息为已读 + if (this.data.characterId) { + proactiveMessage.markAsRead(this.data.characterId) + } + }, + + /** + * 标记会话已读 + * 进入聊天详情页时调用,清除未读数 + * @param {string} conversationId - 会话ID + */ + async markConversationAsRead(conversationId) { + if (!conversationId) { + console.log('[chat-detail] 没有conversationId,跳过标记已读') + return + } + + console.log('[chat-detail] 开始调用标记已读接口,conversationId:', conversationId) + + try { + const res = await api.chat.markAsRead(conversationId) + console.log('[chat-detail] 标记已读API响应:', JSON.stringify(res)) + + if (res.code === 0 || res.success) { + console.log('[chat-detail] 标记已读成功') + } else { + console.log('[chat-detail] 标记已读失败,响应:', res.message || res.error) + } + } catch (err) { + console.error('[chat-detail] 标记已读请求异常:', err) + } + }, + + /** + * 初始化聊天 + */ + async initChat() { + this.setData({ loading: true }) + + try { + // 检查登录状态和Token + const token = wx.getStorageSync(config.STORAGE_KEYS.TOKEN) + const userId = wx.getStorageSync(config.STORAGE_KEYS.USER_ID) + console.log('[chat-detail] ========== 初始化聊天 ==========') + console.log('[chat-detail] Token存在:', !!token) + console.log('[chat-detail] Token长度:', token ? token.length : 0) + console.log('[chat-detail] UserId:', userId) + console.log('[chat-detail] CharacterId:', this.data.characterId) + + // 先加载配额状态 + await this.loadQuotaStatus() + + // 并行加载角色信息和聊天历史 + const [characterRes, historyRes] = await Promise.all([ + this.loadCharacterInfo(), + this.loadChatHistory() + ]) + + this.setData({ loading: false }) + + // 如果没有会话ID,创建新会话 + if (!this.data.conversationId && this.data.characterId) { + await this.createConversation() + } + } catch (err) { + console.error('初始化聊天失败', err) + this.setData({ loading: false }) + util.showError('加载失败') + } + }, + + /** + * 加载聊天配额状态 + */ + async loadQuotaStatus() { + const { characterId } = this.data + + if (!characterId) { + console.log('[chat-detail] 没有角色ID,使用默认配额') + return + } + + try { + const res = await api.chat.getQuota(characterId) + console.log('[chat-detail] 配额API响应:', JSON.stringify(res)) + + if (res && res.success && res.data) { + const quota = res.data + const isUnlocked = quota.is_unlocked || false + const isVip = quota.isVip || (quota.free_chat_time && quota.free_chat_time.isVip) || false + const freeChatTime = quota.free_chat_time || null + + const canChatByVip = !!isVip + const canChatByFreeTime = !!(freeChatTime && freeChatTime.isActive) + const canChat = isUnlocked || canChatByVip || canChatByFreeTime + + console.log('[chat-detail] 解析权限状态:', { isUnlocked, isVip, canChat, canChatByFreeTime }) + + this.setData({ + remainingCount: -1, + maxCount: 0, + isUnlocked: !!isUnlocked, + isVip: !!isVip, + todayCharacterId: quota.today_character_id || '', + freeTime: freeChatTime, + unlockHeartsCost: quota.unlock_config?.hearts_cost || 500 + }) + + // 处理免费畅聊倒计时 + if (freeChatTime && freeChatTime.isActive && freeChatTime.remainingSeconds > 0) { + this.startCountdown(freeChatTime.remainingSeconds) + } else { + this.stopCountdown() + } + + // 如果不能聊天且未解锁,显示解锁弹窗 + if (!canChat && !isUnlocked) { + console.log('[chat-detail] 无聊天权限,显示解锁弹窗') + this.setData({ showUnlockPopup: true }) + } else { + console.log('[chat-detail] 拥有聊天权限,确保解锁弹窗关闭') + this.setData({ showUnlockPopup: false }) + } + } + } catch (err) { + console.log('[chat-detail] 加载权限状态失败', err) + this.setData({ + remainingCount: -1, + maxCount: 0, + isUnlocked: false + }) + } + + // 同时加载用户爱心值 + await this.loadHeartBalance() + }, + + /** + * 开始倒计时 + */ + startCountdown(seconds) { + this.stopCountdown() + + let remaining = seconds + this.setData({ + countdownText: this.formatSeconds(remaining) + }) + + this.countdownTimer = setInterval(() => { + remaining-- + if (remaining <= 0) { + this.stopCountdown() + this.setData({ + 'freeTime.isActive': false, + 'freeTime.remainingSeconds': 0 + }) + } else { + this.setData({ + countdownText: this.formatSeconds(remaining) + }) + } + }, 1000) + }, + + /** + * 停止倒计时 + */ + stopCountdown() { + if (this.countdownTimer) { + clearInterval(this.countdownTimer) + this.countdownTimer = null + } + this.setData({ countdownText: '' }) + }, + + /** + * 格式化秒数为 MM:SS + */ + formatSeconds(s) { + const m = Math.floor(s / 60) + const rs = s % 60 + return `${m}:${rs < 10 ? '0' : ''}${rs}` + }, + + /** + * 加载用户爱心值 + * 使用 /api/auth/me 接口,该接口从 im_users.grass_balance 读取余额 + */ + async loadHeartBalance() { + try { + const res = await api.auth.getCurrentUser() + if (res.success && res.data) { + this.setData({ + heartCount: res.data.grass_balance || 0 + }) + console.log('[chat-detail] 爱心值加载成功:', res.data.grass_balance) + } + } catch (err) { + console.log('加载爱心值失败', err) + } + }, + + /** + * 加载角色信息 + */ + async loadCharacterInfo() { + if (!this.data.characterId) return + + try { + const res = await api.character.getDetail(this.data.characterId) + + if (res.success && res.data) { + // 处理头像URL - 后端已返回完整URL,前端只需兜底处理 + const avatarUrl = imageUrl.getCharacterAvatarUrl(res.data.avatar || res.data.logo) + + // 解析擅长领域 + let skills = res.data.hobbiesTags || res.data.traits || [] + if (!Array.isArray(skills) || skills.length === 0) { + skills = ['情感困惑', '职业压力', '成长创伤'] + } + + // 使用API返回的真实统计数据 + const serviceCount = res.data.serviceCount || 0 + const returnCount = res.data.returnCount || 0 + const avgRating = res.data.avgRating || 4.9 + + this.setData({ + character: { + id: res.data.id, + name: res.data.name, + avatar: avatarUrl, + isOnline: true, + voiceId: res.data.voice_id, + job: res.data.occupation || res.data.companionType || '心理咨询师', + location: res.data.location || res.data.province || '北京', + gender: res.data.gender === 'male' ? '男' : '女', + age: res.data.age || '90后', + education: '本科', + serviceCount: serviceCount, + returnCount: returnCount, + rating: avgRating.toFixed(2), + motto: res.data.openingLine || '每一次倾诉,都是心灵的释放', + qualification: '国家二级心理咨询师 | 情感咨询专家认证', + skills: skills.slice(0, 3), + introduction: res.data.selfIntroduction || res.data.about || '专业心理咨询培训' + } + }) + } + } catch (err) { + console.log('加载角色信息失败', err) + } + }, + + /** + * 加载聊天历史(首次加载最近20条) + */ + async loadChatHistory() { + const { characterId, pageSize } = this.data + + if (!characterId) { + const welcomeMsg = { + id: 'welcome', + text: `你好!我是${this.data.character.name},很高兴认识你~`, + isMe: false, + time: util.formatTime(new Date(), 'HH:mm'), + type: 'text' + } + this.setData({ + messages: [welcomeMsg], + isFirstLoad: false, + hasMore: false + }) + return + } + + try { + console.log('[chat-detail] 开始加载聊天历史, characterId:', characterId) + + // 首次只加载最近20条消息 + const res = await api.chat.getChatHistoryByCharacter(characterId, { + limit: pageSize, + page: 1 + }) + + console.log('[chat-detail] API响应:', JSON.stringify(res).substring(0, 200)) + + if (res.success && res.data && res.data.length > 0) { + console.log('[chat-detail] 收到历史消息数量:', res.data.length) + + const messages = res.data.map(msg => this.transformMessage(msg)) + + this.setData({ + messages, + hasMore: res.data.length >= pageSize, + page: 1, + isFirstLoad: false + }) + + console.log('[chat-detail] 消息已设置, 当前数量:', this.data.messages.length) + console.log('[chat-detail] 首次加载完成,不自动滚动到底部') + } else { + console.log('[chat-detail] 没有历史记录,显示欢迎消息') + const welcomeMsg = { + id: 'welcome', + text: `你好!我是${this.data.character.name},很高兴认识你~`, + isMe: false, + time: util.formatTime(new Date(), 'HH:mm'), + type: 'text' + } + this.setData({ + messages: [welcomeMsg], + isFirstLoad: false, + hasMore: false + }) + } + } catch (err) { + console.log('加载聊天历史失败:', err) + const welcomeMsg = { + id: 'welcome', + text: `你好!我是${this.data.character.name},很高兴认识你~`, + isMe: false, + time: util.formatTime(new Date(), 'HH:mm'), + type: 'text' + } + this.setData({ + messages: [welcomeMsg], + isFirstLoad: false, + hasMore: false + }) + } + }, + + /** + * 加载更多历史消息(向上翻页) + */ + async loadMoreHistory() { + const { characterId, loadingMore, hasMore, page, pageSize, messages } = this.data + + if (loadingMore || !hasMore || !characterId) { + return + } + + console.log('[chat-detail] 开始加载更多历史消息, page:', page + 1) + + this.setData({ loadingMore: true }) + + try { + const res = await api.chat.getChatHistoryByCharacter(characterId, { + limit: pageSize, + page: page + 1 + }) + + if (res.success && res.data && res.data.length > 0) { + console.log('[chat-detail] 加载到更多消息:', res.data.length, '条') + + const newMessages = res.data.map(msg => this.transformMessage(msg)) + + // 将新消息插入到列表开头(历史消息在前) + this.setData({ + messages: [...newMessages, ...messages], + hasMore: res.data.length >= pageSize, + page: page + 1, + loadingMore: false + }) + + console.log('[chat-detail] 历史消息加载完成,总消息数:', this.data.messages.length) + } else { + console.log('[chat-detail] 没有更多历史消息了') + this.setData({ + hasMore: false, + loadingMore: false + }) + } + } catch (err) { + console.error('[chat-detail] 加载更多历史消息失败:', err) + this.setData({ loadingMore: false }) + } + }, + + /** + * 滚动事件监听(检测是否滚动到顶部) + */ + onScroll(e) { + const { scrollTop } = e.detail + + // 滚动到顶部时加载更多历史消息 + if (scrollTop < 50 && !this.data.loadingMore && this.data.hasMore) { + console.log('[chat-detail] 滚动到顶部,触发加载更多') + this.loadMoreHistory() + } + }, + + /** + * 转换消息格式 + */ + transformMessage(msg) { + const baseMessage = { + id: msg.id, + text: msg.content, + isMe: msg.role === 'user', + time: util.formatTime(msg.created_at || msg.timestamp, 'HH:mm'), + type: msg.message_type || 'text' + } + + // 根据消息类型添加额外字段 + if (msg.message_type === 'image' && msg.image_url) { + baseMessage.imageUrl = msg.image_url + } else if (msg.message_type === 'voice' && msg.voice_url) { + baseMessage.audioUrl = msg.voice_url + baseMessage.duration = msg.voice_duration + } else if (msg.message_type === 'gift' && msg.gift_info) { + baseMessage.giftInfo = typeof msg.gift_info === 'string' ? JSON.parse(msg.gift_info) : msg.gift_info + } + + return baseMessage + }, + + /** + * 创建新会话 + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + async createConversation() { + try { + const res = await api.chat.createConversation(this.data.characterId) + + if (res.code === 0 && res.data) { + this.setData({ + conversationId: res.data.id + }) + } else if (res.success && res.data) { + this.setData({ + conversationId: res.data.id + }) + } + } catch (err) { + console.log('创建会话失败', err) + } + }, + + /** + * 返回上一页 + */ + onBack() { + wx.navigateBack() + }, + + /** + * 跳转到角色详情页 + */ + onGoToCharacterDetail() { + const { characterId } = this.data + if (characterId) { + wx.navigateTo({ + url: `/pages/character-detail/character-detail?id=${characterId}` + }) + } + }, + + /** + * 用户头像加载失败时的处理 + */ + onAvatarError() { + this.setData({ + myAvatar: '/images/default-avatar.svg' + }) + }, + + /** + * 更多操作 + */ + onMore() { + wx.showActionSheet({ + itemList: ['查看资料', '清空聊天记录', '举报'], + success: (res) => { + if (res.tapIndex === 0) { + // 查看资料 + wx.navigateTo({ + url: `/pages/character-detail/character-detail?id=${this.data.characterId}` + }) + } else if (res.tapIndex === 1) { + this.clearMessages() + } else if (res.tapIndex === 2) { + util.showSuccess('举报已提交') + } + } + }) + }, + + /** + * 清空消息 + * 只清空聊天记录,不删除会话 + * 会话仍然显示在消息列表中 + */ + async clearMessages() { + const confirmed = await util.showConfirm({ + title: '清空记录', + content: '确定要清空聊天记录吗?此操作不可恢复。' + }) + + if (!confirmed) return + + const { characterId } = this.data + + // 如果没有角色ID,只清空本地消息 + if (!characterId) { + this.setData({ messages: [] }) + util.showSuccess('已清空') + return + } + + wx.showLoading({ title: '清空中...' }) + + try { + // 调用后端API清空聊天记录(使用角色ID) + const res = await api.chat.clearChatHistory(characterId) + + if (res.success || res.code === 0) { + // 清空本地消息列表 + this.setData({ messages: [] }) + util.showSuccess('已清空') + } else { + throw new Error(res.message || '清空失败') + } + } catch (err) { + console.error('清空聊天记录失败:', err) + // 即使API失败,也清空本地消息 + this.setData({ messages: [] }) + util.showSuccess('已清空') + } finally { + wx.hideLoading() + } + }, + + /** + * 输入文字 + */ + onInput(e) { + this.setData({ inputText: e.detail.value }) + }, + + /** + * 发送消息 + */ + async onSend() { + const { inputText, characterId, conversationId, character, isUnlocked, isVip, freeTime, remainingCount } = this.data + + // 只检查输入是否为空 + if (!inputText.trim()) return + + // 检查登录 + if (app.checkNeedLogin()) return + + // 检查聊天权限 + // 1. 如果已解锁或VIP,直接放行 + // 2. 如果未解锁且非VIP,检查免费畅聊时间 + const canChatByFreeTime = !!(freeTime && freeTime.isActive) + const canChatByVip = !!isVip + + if (!isUnlocked && !canChatByVip && !canChatByFreeTime) { + console.log('[chat-detail] 无聊天权限,显示解锁弹窗', { isUnlocked, isVip, canChatByFreeTime }) + this.setData({ showUnlockPopup: true }) + return + } + + const messageText = inputText.trim() + const newId = util.generateId() + + // 添加用户消息到列表 + const userMessage = { + id: newId, + text: messageText, + isMe: true, + time: util.formatTime(new Date(), 'HH:mm'), + type: 'text' // 标记为文字消息 + } + + // 立即清空输入框,允许用户继续输入 + this.setData({ + messages: [...this.data.messages, userMessage], + inputText: '' + }, () => { + // 发送消息后立即滚动到底部 + this.scrollToBottom() + }) + + console.log('[chat-detail] 发送消息') + + // 将消息加入待处理队列 + this.pendingMessages.push(messageText) + + // 如果没有正在等待的定时器,启动延迟处理 + if (!this.messageTimer) { + this.startMessageTimer(characterId, conversationId, character, isUnlocked, remainingCount) + } + }, + + /** + * 待处理消息队列 + */ + pendingMessages: [], + messageTimer: null, + isProcessing: false, + + /** + * 启动消息处理定时器 + * 等待随机 2-8 秒,期间收集用户发送的所有消息 + */ + startMessageTimer(characterId, conversationId, character, isUnlocked, remainingCount) { + // 随机延迟 2-4 秒 + const randomDelay = Math.floor(Math.random() * 2000) + 2000 + + console.log('[chat-detail] 启动消息收集定时器,延迟:', randomDelay, 'ms') + + this.messageTimer = setTimeout(() => { + this.messageTimer = null + this.processPendingMessages(characterId, conversationId, character, isUnlocked, remainingCount) + }, randomDelay) + }, + + /** + * 处理待处理的消息队列 + * 将多条消息合并后发送给 AI + */ + async processPendingMessages(characterId, conversationId, character, isUnlocked, remainingCount) { + if (this.pendingMessages.length === 0) return + + // 如果正在处理中,延迟重试而不是直接丢弃消息 + if (this.isProcessing) { + console.log('[chat-detail] 消息处理中,延迟500ms后重试') + setTimeout(() => { + this.processPendingMessages(characterId, conversationId, character, isUnlocked, remainingCount) + }, 500) + return + } + + this.isProcessing = true + + // 取出所有待处理消息并清空队列 + const messagesToProcess = [...this.pendingMessages] + this.pendingMessages = [] + + // 合并多条消息为一条(用换行分隔) + const combinedMessage = messagesToProcess.join('\n') + + console.log('[chat-detail] 合并处理消息:', messagesToProcess.length, '条') + console.log('[chat-detail] 合并后内容:', combinedMessage) + + // 显示AI正在输入 + this.setData({ isTyping: true }) + + try { + // 构建对话历史(最近10条消息,只包含文字消息) + // 过滤掉图片消息,因为后端不需要处理图片内容 + const conversationHistory = this.data.messages + .slice(-10) + .filter(msg => msg.type !== 'image' && msg.text) // 只保留有文字内容的消息 + .map(msg => ({ + role: msg.isMe ? 'user' : 'assistant', + content: msg.text + })) + + // 发送合并后的消息到后端 + const res = await api.chat.sendMessage({ + character_id: characterId, + conversation_id: this.data.conversationId || conversationId, + message: combinedMessage, + conversationHistory: conversationHistory + }) + + this.setData({ isTyping: false }) + + // 检查是否需要解锁 + if (!res.success && (res.error === 'FREE_CHAT_TIME_EXPIRED' || res.error === 'FREE_CHAT_TIME_NOT_CLAIMED')) { + this.setData({ + showUnlockPopup: true, + 'freeTime.isActive': false + }) + + if (res.error === 'FREE_CHAT_TIME_NOT_CLAIMED') { + wx.showModal({ + title: '领取免费畅聊', + content: '领取100爱心值即可获得60分钟免费畅聊时间,是否现在去领取?', + confirmText: '去领取', + success: (modalRes) => { + if (modalRes.confirm) { + wx.switchTab({ url: '/pages/profile/profile' }) + } + } + }) + } + + this.isProcessing = false + return + } + + // 检查是否切换了角色 + if (!res.success && res.error === 'DIFFERENT_CHARACTER') { + this.setData({ showUnlockPopup: true }) + wx.showToast({ + title: res.message || '今天已与其他角色聊天', + icon: 'none' + }) + this.isProcessing = false + return + } + + if (res.success && res.data) { + // 更新会话ID(如果是新会话) + if (res.data.conversation_id && !this.data.conversationId) { + this.setData({ conversationId: res.data.conversation_id }) + } + + // 更新解锁状态(从后端返回的quota字段) + if (res.data.quota) { + const newIsUnlocked = res.data.quota.is_unlocked + + console.log('[chat-detail] 后端返回解锁状态:', { newIsUnlocked }) + + this.setData({ + isUnlocked: newIsUnlocked || this.data.isUnlocked + }) + } + + // 添加AI回复 + const aiMessage = { + id: res.data.id || util.generateId(), + text: res.data.content || res.data.message, + isMe: false, + time: util.formatTime(new Date(), 'HH:mm'), + audioUrl: res.data.audio_url, + type: 'text' // 标记为文字消息 + } + + this.setData({ + messages: [...this.data.messages, aiMessage] + }, () => { + // AI回复后滚动到底部 + this.scrollToBottom() + }) + } else { + throw new Error(res.error || res.message || '发送失败') + } + } catch (err) { + console.error('发送消息失败', err) + + // 开发模式下使用模拟AI回复 + const config = require('../../config/index') + if (config.DEBUG) { + console.log('[DEV] 使用模拟AI回复') + const mockResponse = this.getMockAIResponse(combinedMessage, character.name) + + const aiMessage = { + id: util.generateId(), + text: mockResponse, + isMe: false, + time: util.formatTime(new Date(), 'HH:mm'), + type: 'text' // 标记为文字消息 + } + + this.setData({ + messages: [...this.data.messages, aiMessage], + isTyping: false + }, () => { + this.scrollToBottom() + }) + + this.isProcessing = false + return + } + + this.setData({ isTyping: false }) + util.showError(err.message || '发送失败,请重试') + } + + this.isProcessing = false + }, + + /** + * 获取模拟AI回复(开发模式) + */ + getMockAIResponse(userMessage, characterName) { + const responses = [ + `嗯,我明白你的意思~`, + `这个问题很有趣呢,让我想想...`, + `谢谢你愿意和我分享这些~`, + `我觉得你说得很有道理!`, + `哈哈,你真的很有趣~`, + `我一直都在这里陪着你哦~`, + `能和你聊天真的很开心!`, + `你今天心情怎么样呀?`, + `我很喜欢和你聊天的感觉~`, + `嗯嗯,我在认真听你说呢~` + ] + + // 根据用户消息内容选择合适的回复 + if (userMessage.includes('你好') || userMessage.includes('嗨') || userMessage.includes('hi')) { + return `你好呀!我是${characterName},很高兴认识你~今天想聊点什么呢?` + } + if (userMessage.includes('名字') || userMessage.includes('叫什么')) { + return `我叫${characterName}呀,你可以这样叫我~` + } + if (userMessage.includes('喜欢')) { + return `我也很喜欢和你聊天呢!你喜欢什么呀?` + } + if (userMessage.includes('开心') || userMessage.includes('高兴')) { + return `看到你开心我也很开心呢!希望你每天都这么快乐~` + } + if (userMessage.includes('难过') || userMessage.includes('伤心') || userMessage.includes('不开心')) { + return `抱抱你~有什么不开心的事情可以和我说说,我会一直陪着你的。` + } + + // 随机选择一个回复 + const randomIndex = Math.floor(Math.random() * responses.length) + return responses[randomIndex] + }, + + /** + * 滚动到底部(仅在发送/接收新消息时调用) + * 使用 scroll-into-view 属性,自动滚动到最后一条消息 + */ + scrollToBottom() { + const messages = this.data.messages + if (messages && messages.length > 0) { + // 使用 setTimeout 确保 DOM 已更新 + setTimeout(() => { + this.setData({ + scrollIntoView: `msg-${messages.length - 1}` + }) + }, 100) + } + }, + + /** + * 滚动到顶部(加载更多历史消息后保持位置) + */ + scrollToTop() { + this.setData({ + scrollTop: 0 + }) + }, + + /** + * 切换语音模式 + */ + onVoiceMode() { + this.setData({ + isVoiceMode: !this.data.isVoiceMode, + showEmoji: false + }) + }, + + /** + * 开始录音 + */ + onVoiceStart() { + this.setData({ isRecording: true }) + + wx.showToast({ + title: '正在录音...', + icon: 'none', + duration: 60000 + }) + + // 开始录音 + const recorderManager = wx.getRecorderManager() + recorderManager.start({ + duration: 60000, + format: 'mp3' + }) + + this.recorderManager = recorderManager + }, + + /** + * 结束录音 + */ + onVoiceEnd() { + this.setData({ isRecording: false }) + wx.hideToast() + + if (this.recorderManager) { + this.recorderManager.stop() + + this.recorderManager.onStop((res) => { + // 发送语音消息(暂时转为文字) + const newId = util.generateId() + const voiceMessage = { + id: newId, + text: '[语音消息]', + isMe: true, + time: util.formatTime(new Date(), 'HH:mm'), + type: 'voice', + audioUrl: res.tempFilePath + } + + this.setData({ + messages: [...this.data.messages, voiceMessage] + }) + + this.scrollToBottom() + }) + } + }, + + /** + * 切换表情面板 + */ + onEmojiToggle() { + // 收起键盘 + this.setData({ + inputFocus: false + }) + + // 延迟一点再显示面板,确保键盘先收起 + setTimeout(() => { + this.setData({ + showEmoji: !this.data.showEmoji, + showMorePanel: false, + isVoiceMode: false + }) + }, 50) + }, + + /** + * 点击+号,显示更多功能面板 + */ + onAddMore() { + // 收起键盘 + this.setData({ + inputFocus: false + }) + + // 延迟一点再显示面板,确保键盘先收起 + setTimeout(() => { + this.setData({ + showMorePanel: !this.data.showMorePanel, + showEmoji: false, + isVoiceMode: false + }) + }, 50) + }, + + /** + * 关闭所有面板 + */ + onClosePanels() { + this.setData({ + showEmoji: false, + showMorePanel: false + }) + }, + + /** + * 点击聊天区域关闭面板 + */ + onTapChatArea() { + if (this.data.showEmoji || this.data.showMorePanel) { + this.setData({ + showEmoji: false, + showMorePanel: false + }) + } + }, + + /** + * 选择表情 + */ + onEmojiSelect(e) { + const emoji = e.currentTarget.dataset.emoji + this.setData({ + inputText: this.data.inputText + emoji + }) + }, + + /** + * 播放语音消息 + */ + onPlayVoice(e) { + const { id, url } = e.currentTarget.dataset + + if (!url) { + util.showError('语音不可用') + return + } + + const innerAudioContext = wx.createInnerAudioContext() + innerAudioContext.src = url + innerAudioContext.play() + + innerAudioContext.onEnded(() => { + innerAudioContext.destroy() + }) + }, + + /** + * 播放AI语音 + */ + async onPlayAIVoice(e) { + const { id, text } = e.currentTarget.dataset + const { character } = this.data + + if (!character.voiceId) { + util.showError('该角色暂不支持语音') + return + } + + util.showLoading('生成语音中...') + + try { + const res = await api.tts.synthesize({ + text: text, + voice_id: character.voiceId, + character_id: character.id + }) + + util.hideLoading() + + if (res.success && res.data && res.data.audio_url) { + const innerAudioContext = wx.createInnerAudioContext() + innerAudioContext.src = res.data.audio_url + innerAudioContext.play() + } else { + util.showError('语音生成失败') + } + } catch (err) { + util.hideLoading() + util.showError('语音生成失败') + } + }, + + /** + * 开始聊天(免费倾诉) + */ + onStartChat() { + // 聚焦输入框 + this.setData({ showEmoji: false }) + }, + + /** + * 显示人物介绍弹窗 + */ + onShowProfile() { + this.setData({ showProfilePopup: true }) + }, + + /** + * 关闭人物介绍弹窗 + */ + onCloseProfile() { + this.setData({ showProfilePopup: false }) + }, + + /** + * 查看评价 + */ + onShowReviews() { + // 生成模拟评价数据 + const mockReviews = [ + { + id: 1, + phone: '138****6172', + date: '2024-12-15 14:32', + content: '老师很有耐心,倾听我的问题后给出了很中肯的建议。咨询后感觉心里轻松了很多,对未来也有了新的规划...', + tags: ['专业', '耐心', '有效果'], + reply: '谢谢您的信任,很高兴能够帮助到您。希望您能继续保持积极的心态,有任何问题随时可以来找我交流。', + likes: 23 + }, + { + id: 2, + phone: '186****3298', + date: '2024-12-10 09:15', + content: '第一次尝试心理咨询,老师非常专业,让我感觉很放松。通过几次咨询,我对自己的情绪有了更好的认识...', + tags: ['专业', '温暖', '有帮助'], + reply: '能够陪伴您成长是我的荣幸,继续加油!', + likes: 15 + }, + { + id: 3, + phone: '159****7721', + date: '2024-12-05 20:48', + content: '老师的声音很温柔,聊天的过程中感觉很舒服。虽然问题还在,但是心态好了很多,会继续找老师咨询的...', + tags: ['温柔', '善于倾听'], + reply: '', + likes: 8 + }, + { + id: 4, + phone: '177****4532', + date: '2024-11-28 16:22', + content: '咨询师很专业,能够快速理解我的问题并给出建议。性价比很高,会推荐给朋友...', + tags: ['专业', '高效'], + reply: '', + likes: 12 + }, + { + id: 5, + phone: '133****8965', + date: '2024-11-20 11:05', + content: '非常好的一次体验,老师很有同理心,让我感受到了被理解和支持...', + tags: ['有同理心', '支持'], + reply: '感谢您的认可,祝您生活愉快!', + likes: 6 + } + ] + + this.setData({ + showReviewPopup: true, + reviews: mockReviews + }) + }, + + /** + * 关闭评价弹窗 + */ + onCloseReviews() { + this.setData({ showReviewPopup: false }) + }, + + /** + * 阻止弹窗滚动穿透 + */ + preventMove() { + return false + }, + + /** + * 关闭解锁弹窗 + */ + closeUnlockPopup() { + this.setData({ showUnlockPopup: false }) + }, + + /** + * 跳转到个人中心(去领取奖励) + */ + onGoToProfile() { + wx.switchTab({ + url: '/pages/profile/profile' + }) + }, + + /** + * 爱心兑换解锁 + */ + async onExchangeHearts() { + const { character, heartCount, unlockHeartsCost } = this.data + const config = require('../../config/index') + + // 检查登录 + const token = wx.getStorageSync(config.STORAGE_KEYS.TOKEN) + if (!token) { + wx.showToast({ title: '请先登录', icon: 'none' }) + setTimeout(() => { + wx.navigateTo({ url: '/pages/login/login' }) + }, 1500) + return + } + + // 检查爱心值,不足时提示并跳转充值页面 + if (heartCount < unlockHeartsCost) { + wx.showToast({ title: '爱心值不足,去充值', icon: 'none' }) + setTimeout(() => { + this.setData({ showUnlockPopup: false }) + wx.navigateTo({ url: '/pages/recharge/recharge' }) + }, 1500) + return + } + + wx.showLoading({ title: '兑换中...' }) + + try { + const res = await api.character.unlock({ + character_id: character.id, + unlock_type: 'hearts' + }) + + wx.hideLoading() + + if (res.success || res.code === 0) { + wx.showToast({ title: '解锁成功', icon: 'success' }) + + // 更新状态,使用后端返回的剩余爱心数 + const remainingHearts = res.data?.remaining_hearts ?? (heartCount - unlockHeartsCost) + this.setData({ + heartCount: remainingHearts, + isUnlocked: true, + remainingCount: -1, // -1 表示无限 + showUnlockPopup: false + }) + } else { + wx.showToast({ title: res.message || '兑换失败', icon: 'none' }) + } + } catch (err) { + wx.hideLoading() + console.error('爱心兑换失败', err) + wx.showToast({ title: '网络错误,请重试', icon: 'none' }) + } + }, + + /** + * 直接购买解锁(9.9元) + * 使用 /api/payment/unified-order 接口 + * 测试模式下返回 testMode: true,订单直接完成,无需调用微信支付 + */ + async onPurchaseDirect() { + const { character } = this.data + const config = require('../../config/index') + + // 检查登录 + const token = wx.getStorageSync(config.STORAGE_KEYS.TOKEN) + if (!token) { + wx.showToast({ title: '请先登录', icon: 'none' }) + setTimeout(() => { + wx.navigateTo({ url: '/pages/login/login' }) + }, 1500) + return + } + + wx.showLoading({ title: '创建订单中...' }) + + try { + // 调用统一支付订单接口 + const res = await api.payment.createUnifiedOrder({ + type: 'character_unlock', + character_id: character.id, + amount: 9.9 + }) + + wx.hideLoading() + + console.log('[chat-detail onPurchaseDirect] API返回:', JSON.stringify(res)) + + if (res.success) { + // 检查是否为测试模式(兼容多种判断方式) + const isTestMode = res.testMode || res.test_mode || res.data?.testMode || res.data?.test_mode || + // 如果 payParams.package 包含 mock_prepay,也认为是测试模式 + (res.payParams?.package && res.payParams.package.includes('mock_prepay')) + + if (isTestMode) { + // 测试模式:订单已直接完成,无需调用微信支付 + wx.showToast({ title: '购买成功', icon: 'success' }) + + // 重新加载配额状态 + await this.loadQuotaStatus() + + // 更新状态 + this.setData({ + isUnlocked: true, + remainingCount: -1, + showUnlockPopup: false + }) + } else { + // 正式模式:调用微信支付 + const payParams = res.payParams || res.pay_params || res.data?.payParams || res.data?.pay_params + + if (!payParams || !payParams.timeStamp) { + wx.showToast({ title: '支付参数错误', icon: 'none' }) + return + } + + wx.requestPayment({ + timeStamp: payParams.timeStamp, + nonceStr: payParams.nonceStr, + package: payParams.package, + signType: payParams.signType || 'RSA', + paySign: payParams.paySign, + success: async () => { + wx.showToast({ title: '解锁成功', icon: 'success' }) + + // 重新加载配额状态 + await this.loadQuotaStatus() + + // 更新状态 + this.setData({ + isUnlocked: true, + remainingCount: -1, + showUnlockPopup: false + }) + }, + fail: (err) => { + if (err.errMsg !== 'requestPayment:fail cancel') { + wx.showToast({ title: '支付失败', icon: 'none' }) + } + } + }) + } + } else { + wx.showToast({ title: res.message || '创建订单失败', icon: 'none' }) + } + } catch (err) { + wx.hideLoading() + console.error('购买解锁失败', err) + wx.showToast({ title: '网络错误,请重试', icon: 'none' }) + } + }, + + /** + * 拍照 + */ + onTakePhoto() { + this.setData({ showMorePanel: false }) + + wx.chooseMedia({ + count: 1, + mediaType: ['image'], + sourceType: ['camera'], + camera: 'back', + success: (res) => { + const tempFilePath = res.tempFiles[0].tempFilePath + this.sendImageMessage(tempFilePath) + }, + fail: (err) => { + if (err.errMsg !== 'chooseMedia:fail cancel') { + util.showError('拍照失败') + } + } + }) + }, + + /** + * 从相册选择图片 + */ + onChooseImage() { + this.setData({ showMorePanel: false }) + + wx.chooseMedia({ + count: 9, + mediaType: ['image'], + sourceType: ['album'], + success: (res) => { + res.tempFiles.forEach(file => { + this.sendImageMessage(file.tempFilePath) + }) + }, + fail: (err) => { + if (err.errMsg !== 'chooseMedia:fail cancel') { + util.showError('选择图片失败') + } + } + }) + }, + + /** + * 发送图片消息 + * 发送图片后,先上传到服务器,然后保存到数据库,最后AI返回预设的图片回复话术 + */ + async sendImageMessage(tempFilePath) { + const newId = util.generateId() + + // 先添加本地消息(显示上传中状态) + const imageMessage = { + id: newId, + type: 'image', + imageUrl: tempFilePath, + isMe: true, + time: util.formatTime(new Date(), 'HH:mm'), + uploading: true // 标记为上传中 + } + + this.setData({ + messages: [...this.data.messages, imageMessage] + }, () => { + this.scrollToBottom() + }) + + try { + // 1. 上传图片到服务器 + console.log('[chat-detail] 开始上传图片:', tempFilePath) + + // 检查登录状态 + const token = wx.getStorageSync(config.STORAGE_KEYS.TOKEN) + console.log('[chat-detail] Token存在:', !!token) + console.log('[chat-detail] Token长度:', token ? token.length : 0) + + const uploadRes = await api.uploadFile(tempFilePath, 'uploads') + + if (!uploadRes || !uploadRes.success || !uploadRes.data || !uploadRes.data.url) { + throw new Error('图片上传失败') + } + + const imageUrl = uploadRes.data.url + console.log('[chat-detail] 图片上传成功:', imageUrl) + + // 2. 更新本地消息,移除上传中状态 + const messages = this.data.messages.map(msg => { + if (msg.id === newId) { + return { ...msg, imageUrl: imageUrl, uploading: false } + } + return msg + }) + this.setData({ messages }) + + // 3. 保存图片消息到数据库 + try { + await api.chat.sendImage({ + character_id: this.data.characterId, + conversation_id: this.data.conversationId, + image_url: imageUrl + }) + console.log('[chat-detail] 图片消息已保存到数据库') + } catch (err) { + console.error('[chat-detail] 保存图片消息失败:', err) + // 保存失败不影响显示,继续执行 + } + + // 4. 使用独立的图片回复定时器,避免与文字消息冲突 + // 随机延迟 2-4 秒后显示AI回复 + const randomDelay = Math.floor(Math.random() * 2000) + 2000 + + // 存储定时器ID,用于页面卸载时清理 + const imageReplyTimer = setTimeout(async () => { + // 检查页面是否还存在 + if (!this.data) return + + // 标记图片回复正在处理中 + this.isImageReplyProcessing = true + + // 只有在没有正在输入状态时才显示(避免覆盖文字消息的输入状态) + const shouldShowTyping = !this.data.isTyping + if (shouldShowTyping) { + this.setData({ isTyping: true }) + } + + try { + // 调用图片回复话术API + const res = await api.imageReply.getRandom() + + // 检查页面是否还存在 + if (!this.data) { + this.isImageReplyProcessing = false + return + } + + // 只有当前是图片回复触发的isTyping时才关闭 + if (shouldShowTyping && !this.isProcessing) { + this.setData({ isTyping: false }) + } + + if (res && res.success && res.data && res.data.content) { + // 添加AI回复 + const aiMessage = { + id: util.generateId(), + text: res.data.content, + isMe: false, + time: util.formatTime(new Date(), 'HH:mm'), + type: 'text' // 标记为文字消息 + } + + this.setData({ + messages: [...this.data.messages, aiMessage] + }, () => { + this.scrollToBottom() + }) + } else { + // API返回失败,使用默认回复 + this.showDefaultImageReply() + } + } catch (err) { + console.error('[chat-detail] 获取图片回复话术失败:', err) + if (this.data) { + // 只有当前是图片回复触发的isTyping时才关闭 + if (shouldShowTyping && !this.isProcessing) { + this.setData({ isTyping: false }) + } + // 使用默认回复 + this.showDefaultImageReply() + } + } finally { + // 清除图片回复处理标记 + this.isImageReplyProcessing = false + } + }, randomDelay) + + // 保存定时器引用,用于清理 + if (!this.imageReplyTimers) { + this.imageReplyTimers = [] + } + this.imageReplyTimers.push(imageReplyTimer) + + } catch (err) { + console.error('[chat-detail] 图片上传失败:', err) + + // 更新消息状态为失败 + const messages = this.data.messages.map(msg => { + if (msg.id === newId) { + return { ...msg, uploading: false, uploadFailed: true } + } + return msg + }) + this.setData({ messages }) + + util.showError('图片发送失败') + } + }, + + /** + * 显示默认图片回复(API失败时的兜底) + */ + showDefaultImageReply() { + const defaultReplies = [ + '哇,这张图片真好看!', + '谢谢你分享这张图片给我~', + '这张图片很有意思呢!', + '收到你的图片啦,真棒!' + ] + const randomIndex = Math.floor(Math.random() * defaultReplies.length) + + const aiMessage = { + id: util.generateId(), + text: defaultReplies[randomIndex], + isMe: false, + time: util.formatTime(new Date(), 'HH:mm'), + type: 'text' // 标记为文字消息 + } + + this.setData({ + messages: [...this.data.messages, aiMessage] + }, () => { + this.scrollToBottom() + }) + }, + + /** + * 预览图片 + */ + onPreviewImage(e) { + const url = e.currentTarget.dataset.url + const urls = this.data.messages + .filter(msg => msg.type === 'image') + .map(msg => msg.imageUrl) + + wx.previewImage({ + current: url, + urls: urls + }) + }, + + /** + * 发送礼物 + */ + onSendGift() { + this.setData({ showMorePanel: false }) + + // 加载礼物列表 + this.loadGiftList() + this.setData({ showGiftPopup: true }) + }, + + /** + * 加载礼物列表 + */ + async loadGiftList() { + // 模拟礼物数据 + const giftList = [ + { id: 1, name: '玫瑰花', price: 10, image: '/images/gift-rose.png' }, + { id: 2, name: '爱心', price: 20, image: '/images/gift-heart.png' }, + { id: 3, name: '蛋糕', price: 50, image: '/images/gift-cake.png' }, + { id: 4, name: '钻戒', price: 100, image: '/images/gift-ring.png' }, + { id: 5, name: '跑车', price: 500, image: '/images/gift-car.png' }, + { id: 6, name: '城堡', price: 1000, image: '/images/gift-castle.png' }, + { id: 7, name: '火箭', price: 2000, image: '/images/gift-rocket.png' }, + { id: 8, name: '皇冠', price: 5000, image: '/images/gift-crown.png' } + ] + + this.setData({ giftList }) + }, + + /** + * 选择礼物 + */ + onSelectGift(e) { + const gift = e.currentTarget.dataset.gift + this.setData({ selectedGift: gift }) + }, + + /** + * 关闭礼物弹窗 + */ + onCloseGiftPopup() { + this.setData({ + showGiftPopup: false, + selectedGift: null + }) + }, + + /** + * 确认发送礼物 + */ + async onConfirmSendGift() { + const { selectedGift, userFlowers, character } = this.data + + if (!selectedGift) { + util.showError('请选择礼物') + return + } + + if (userFlowers < selectedGift.price) { + util.showError('花朵余额不足') + // 跳转充值页面 + setTimeout(() => { + wx.navigateTo({ url: '/pages/recharge/recharge' }) + }, 1500) + return + } + + // 发送礼物消息 + const newId = util.generateId() + const giftMessage = { + id: newId, + type: 'gift', + text: `送出了 ${selectedGift.name}`, + giftInfo: selectedGift, + isMe: true, + time: util.formatTime(new Date(), 'HH:mm') + } + + this.setData({ + messages: [...this.data.messages, giftMessage], + showGiftPopup: false, + selectedGift: null, + userFlowers: userFlowers - selectedGift.price + }, () => { + this.scrollToBottom() + }) + + util.showSuccess('礼物已送出') + + // TODO: 调用后端API发送礼物 + }, + + /** + * 语音通话 + */ + onVoiceCall() { + this.setData({ showMorePanel: false }) + + wx.showModal({ + title: '语音通话', + content: '语音通话功能即将上线,敬请期待~', + showCancel: false, + confirmText: '知道了' + }) + }, + + /** + * 常用语 + */ + onQuickReply() { + this.setData({ showMorePanel: false }) + + wx.showActionSheet({ + itemList: this.data.quickReplies, + success: (res) => { + const selectedReply = this.data.quickReplies[res.tapIndex] + this.setData({ inputText: selectedReply }) + } + }) + }, + + /** + * 约时间 + */ + onScheduleTime() { + this.setData({ showMorePanel: false }) + + wx.showModal({ + title: '约时间', + content: '预约功能即将上线,敬请期待~', + showCancel: false, + confirmText: '知道了' + }) + }, + + /** + * 抢红包 + */ + onRedPacket() { + this.setData({ showMorePanel: false }) + + wx.showModal({ + title: '抢红包', + content: '红包功能即将上线,敬请期待~', + showCancel: false, + confirmText: '知道了' + }) + }, + + /** + * 测结果 + */ + onTestResult() { + this.setData({ showMorePanel: false }) + + // 跳转到测试结果页面 + wx.showModal({ + title: '测结果', + content: '心理测试功能即将上线,敬请期待~', + showCancel: false, + confirmText: '知道了' + }) + }, + + /** + * 语音录制相关方法 + */ + onVoiceTouchStart(e) { + this.touchStartY = e.touches[0].clientY + this.setData({ + isRecording: true, + voiceCancelHint: false, + recordingDuration: 0 + }) + + // 开始录音 + const recorderManager = wx.getRecorderManager() + recorderManager.start({ + duration: 60000, + format: 'mp3' + }) + + this.recorderManager = recorderManager + + // 录音时长计时 + this.recordingTimer = setInterval(() => { + this.setData({ + recordingDuration: this.data.recordingDuration + 1 + }) + }, 1000) + }, + + onVoiceTouchMove(e) { + const moveY = e.touches[0].clientY + const diff = this.touchStartY - moveY + + // 上滑超过50px显示取消提示 + this.setData({ + voiceCancelHint: diff > 50 + }) + }, + + onVoiceTouchEnd() { + clearInterval(this.recordingTimer) + + const { voiceCancelHint, recordingDuration, characterId, character, isUnlocked, remainingCount } = this.data + + this.setData({ isRecording: false }) + + if (this.recorderManager) { + this.recorderManager.stop() + + if (voiceCancelHint) { + // 取消发送 + util.showToast('已取消') + return + } + + if (recordingDuration < 1) { + util.showError('录音时间太短') + return + } + + this.recorderManager.onStop(async (res) => { + console.log('[chat-detail] 录音完成:', res.tempFilePath, '时长:', recordingDuration) + + // 先显示语音消息(带识别中状态) + const newId = util.generateId() + const voiceMessage = { + id: newId, + type: 'voice', + audioUrl: res.tempFilePath, + duration: recordingDuration, + isMe: true, + time: util.formatTime(new Date(), 'HH:mm'), + recognizing: true, // 识别中状态 + recognizedText: '' // 识别出的文字 + } + + this.setData({ + messages: [...this.data.messages, voiceMessage] + }, () => { + this.scrollToBottom() + }) + + // 进行语音识别 + try { + wx.showLoading({ title: '语音识别中...' }) + + // 读取音频文件并转换为base64 + const fs = wx.getFileSystemManager() + const audioData = fs.readFileSync(res.tempFilePath) + const audioBase64 = wx.arrayBufferToBase64(audioData) + + // 调用语音识别API + const recognizeRes = await api.speech.recognize({ + audio: audioBase64, + format: 'mp3' + }) + + wx.hideLoading() + + let recognizedText = '' + if (recognizeRes.success && recognizeRes.data && recognizeRes.data.text) { + recognizedText = recognizeRes.data.text + console.log('[chat-detail] 语音识别结果:', recognizedText) + } else { + // 识别失败,使用默认文字 + recognizedText = '[语音消息]' + console.log('[chat-detail] 语音识别失败,使用默认文字') + } + + // 更新语音消息的识别状态 + const messages = this.data.messages.map(msg => { + if (msg.id === newId) { + return { ...msg, recognizing: false, recognizedText } + } + return msg + }) + this.setData({ messages }) + + // 如果识别出了有效文字,发送给AI + if (recognizedText && recognizedText !== '[语音消息]') { + // 检查聊天权限 + const canChatByFreeTime = !!(this.data.freeTime && this.data.freeTime.isActive) + const canChatByVip = !!this.data.isVip + + if (!isUnlocked && !canChatByVip && !canChatByFreeTime) { + console.log('[chat-detail] 语音消息无聊天权限', { isUnlocked, isVip, canChatByFreeTime }) + this.setData({ showUnlockPopup: true }) + return + } + + // 将识别出的文字加入待处理队列 + this.pendingMessages.push(recognizedText) + + // 如果没有正在等待的定时器,启动延迟处理 + if (!this.messageTimer) { + this.startMessageTimer(characterId, this.data.conversationId, character, isUnlocked, remainingCount) + } + } + + } catch (err) { + wx.hideLoading() + console.error('[chat-detail] 语音识别失败:', err) + + // 更新消息状态 + const messages = this.data.messages.map(msg => { + if (msg.id === newId) { + return { ...msg, recognizing: false, recognizedText: '[语音消息]' } + } + return msg + }) + this.setData({ messages }) + + util.showError('语音识别失败') + } + }) + } + }, + + onVoiceTouchCancel() { + clearInterval(this.recordingTimer) + this.setData({ isRecording: false }) + + if (this.recorderManager) { + this.recorderManager.stop() + } + }, + + /** + * 消息长按操作 + */ + onMessageLongPress(e) { + const item = e.currentTarget.dataset.item + + wx.showActionSheet({ + itemList: ['复制', '删除'], + success: (res) => { + if (res.tapIndex === 0) { + // 复制 + wx.setClipboardData({ + data: item.text, + success: () => { + util.showSuccess('已复制') + } + }) + } else if (res.tapIndex === 1) { + // 删除 + const messages = this.data.messages.filter(msg => msg.id !== item.id) + this.setData({ messages }) + } + } + }) + }, + + /** + * 阻止事件冒泡 + */ + preventBubble() { + return + }, + + /** + * 阻止触摸穿透 + */ + preventTouchMove() { + return false + } +}) diff --git a/pages/chat-detail/chat-detail.json b/pages/chat-detail/chat-detail.json new file mode 100644 index 0000000..b16ef3e --- /dev/null +++ b/pages/chat-detail/chat-detail.json @@ -0,0 +1,4 @@ +{ + "navigationStyle": "custom", + "usingComponents": {} +} diff --git a/pages/chat-detail/chat-detail.wxml b/pages/chat-detail/chat-detail.wxml new file mode 100644 index 0000000..cd399a1 --- /dev/null +++ b/pages/chat-detail/chat-detail.wxml @@ -0,0 +1,406 @@ + + + + + + + + + + + + + + 返回 + + + + + + + + {{character.name[0] || 'AI'}} + + + {{character.name}} + + + + + + + + + + + + + + + + 加载中... + + 向上滑动加载更多 + + + + 没有更多消息了 + + + + + 与 {{character.name}} 的加密对话 + + + + + + + + + + + {{character.name[0] || 'AI'}} + + + + + + {{item.text}} + + + + + + + + + + + + + {{item.duration || 1}}″ + + + {{item.time}} + + + + + + {{item.time}} + + + + + + + + + {{item.text}} + + + + + + + + {{item.duration || 1}}″ + + + + + + + + + 识别中... + {{item.recognizedText}} + + + + + + {{item.text}} + + + {{item.time}} + + + + + + + + + + + + + + + + {{character.name[0] || 'AI'}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{isRecording ? (voiceCancelHint ? '松开 取消' : '松开 发送') : '按住 说话'}} + + + + + + + + + + + + + + + + + + + + + + + + + + + {{item}} + + + + + + + + + + + + + + + + 照片 + + + + + + + 拍摄 + + + + + + + 礼物 + + + + + + + + + + + + + + + + + + + + {{voiceCancelHint ? '松开手指,取消发送' : '手指上划,取消发送'}} + {{recordingDuration}}″ + + + + + + + + 选择礼物 + + + + + + + + + {{item.name}} + + + {{item.price}} + + + + + + + 我的花朵: + + {{userFlowers || 0}} + + + 赠送 + + + + + + + + + + + × + + + + + + + + + + + + + + + + + + 解锁与 + {{character.name}} + 的专属聊天 + + + + + + + + + + + + + {{unlockHeartsCost}} 爱心 + {{heartCount >= unlockHeartsCost ? '爱心值充足 立即兑换' : '爱心值不足 去充值'}} + + + + 兑换 + + + + + + + + ¥ + + + 9.9元 + 限时特惠 立即购买 + + + + 购买 + + + + + + 暂不需要 + + + + + diff --git a/pages/chat-detail/chat-detail.wxss b/pages/chat-detail/chat-detail.wxss new file mode 100644 index 0000000..51cb82c --- /dev/null +++ b/pages/chat-detail/chat-detail.wxss @@ -0,0 +1,1659 @@ +/* AI智能体聊天详情页样式 - 基于Figma设计 */ + +/* 页面容器 */ +.page-container { + min-height: 100vh; + background: #F5F2FD; + display: flex; + flex-direction: column; + position: relative; +} + +/* 聊天区域包装器 - 使用固定定位确保正确布局 */ +.chat-area-wrapper { + position: fixed; + left: 0; + right: 0; + bottom: 120rpx; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* 面板打开时的透明遮罩层 */ +.panel-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: transparent; + z-index: 98; +} + +/* 状态栏区域 */ +.status-bar-area { + position: fixed; + top: 0; + left: 0; + right: 0; + background: rgba(242, 237, 255, 0.6); + z-index: 101; +} + +/* 顶部导航栏 */ +.nav-header { + position: fixed; + left: 0; + right: 0; + background: rgba(255, 255, 255, 0.95); + border-bottom: 2rpx solid #F3F4F6; + z-index: 100; +} + +.nav-content { + display: flex; + align-items: center; + justify-content: space-between; + height: 98rpx; + padding: 0 16rpx; +} + +/* 返回按钮 */ +.nav-back { + display: flex; + align-items: center; + gap: 4rpx; + padding: 16rpx; + min-width: 160rpx; +} + +.back-icon { + width: 56rpx; + height: 56rpx; +} + +.back-text { + font-size: 34rpx; + font-weight: 700; + color: #914584; +} + +/* 中间角色信息 */ +.nav-center { + display: flex; + align-items: center; + gap: 16rpx; +} + +.nav-avatar-wrap { + width: 64rpx; + height: 64rpx; + border-radius: 50%; + overflow: hidden; + border: 2rpx solid #E5E7EB; +} + +.nav-avatar { + width: 100%; + height: 100%; +} + +.nav-avatar-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #E9D5FF 0%, #FDF2F8 100%); + font-size: 28rpx; + font-weight: 600; + color: #8B5CF6; +} + +.nav-name { + font-size: 34rpx; + font-weight: 700; + color: #101828; +} + +.online-dot { + width: 16rpx; + height: 16rpx; + background: #00C950; + border-radius: 50%; +} + +/* 更多按钮 - 已移除,改为占位 */ +.nav-right-placeholder { + min-width: 160rpx; +} + +/* 聊天内容区域 - 使用100%高度填满父容器 */ +.chat-scroll { + height: 100%; + padding: 0 32rpx; + padding-top: 20rpx; + padding-bottom: 20rpx; + box-sizing: border-box; +} + +/* 隐藏滚动条 */ +.chat-scroll::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} + +/* 加载更多提示 */ +.load-more-hint { + padding: 20rpx 0; + text-align: center; +} + +.load-more-content { + display: flex; + align-items: center; + justify-content: center; + gap: 16rpx; +} + +.loading-spinner { + width: 32rpx; + height: 32rpx; + border: 4rpx solid #E5E7EB; + border-top-color: #b06ab3; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.load-more-text { + font-size: 24rpx; + color: #9CA3AF; +} + +.no-more-hint { + padding: 20rpx 0; + text-align: center; +} + +.no-more-hint text { + font-size: 24rpx; + color: #9CA3AF; +} + +/* 加密对话提示 */ +.encrypt-hint { + text-align: center; + padding: 32rpx 0 48rpx; +} + +.encrypt-hint text { + font-size: 24rpx; + color: #99A1AF; +} + +/* 聊天消息列表 */ +.chat-list { + display: flex; + flex-direction: column; + gap: 48rpx; +} + +/* 单条消息 */ +.chat-item { + display: flex; + gap: 24rpx; + align-items: flex-start; +} + +/* 用户消息靠右显示 */ +.chat-item.me { + justify-content: flex-end; +} + +/* 头像 */ +.avatar-wrap { + width: 88rpx; + height: 88rpx; + border-radius: 50%; + overflow: hidden; + flex-shrink: 0; + border: 2rpx solid rgba(255, 255, 255, 0.5); + box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1), 0 2rpx 6rpx rgba(0, 0, 0, 0.1); +} + +.chat-avatar { + width: 100%; + height: 100%; +} + +.avatar-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #E9D5FF 0%, #FDF2F8 100%); +} + +.avatar-placeholder.user { + background: #E5E7EB; +} + +.avatar-text { + font-size: 32rpx; + font-weight: 600; + color: #8B5CF6; +} + +.avatar-placeholder.user .avatar-text { + color: #6A7282; +} + +/* 用户头像特殊样式 - 确保显示 */ +.user-avatar { + display: flex !important; + visibility: visible !important; +} + +/* 消息内容 */ +.message-content { + max-width: 540rpx; + display: flex; + flex-direction: column; + gap: 8rpx; +} + +.message-content.me { + align-items: flex-end; +} + +/* 聊天气泡 */ +.chat-bubble { + padding: 24rpx 40rpx; + word-break: break-all; + box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1), 0 2rpx 6rpx rgba(0, 0, 0, 0.1); +} + +/* AI消息气泡 - 白色背景,左上角小圆角 */ +.chat-bubble.other { + background: #FFFFFF; + border-radius: 12rpx 44rpx 44rpx 44rpx; +} + +/* 用户消息气泡 - 紫色背景,右上角小圆角 */ +.chat-bubble.me { + background: #914584; + border-radius: 44rpx 12rpx 44rpx 44rpx; +} + +/* 消息文字 */ +.chat-text { + font-size: 34rpx; + line-height: 1.625; +} + +.chat-bubble.other .chat-text { + color: #1E2939; +} + +.chat-bubble.me .chat-text { + color: #FFFFFF; +} + +/* 消息时间 */ +.message-time { + font-size: 22rpx; + color: #99A1AF; + padding: 0 8rpx; +} + +/* 消息操作区域 */ +.message-actions { + display: flex; + align-items: center; + gap: 16rpx; +} + +/* AI语音播放按钮 */ +.play-voice-btn { + width: 48rpx; + height: 48rpx; + background: rgba(145, 69, 132, 0.1); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.play-voice-icon { + width: 28rpx; + height: 28rpx; + opacity: 0.8; +} + +/* 图片消息气泡 */ +.chat-bubble-image { + max-width: 400rpx; + border-radius: 24rpx; + overflow: hidden; + box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1); +} + +.chat-bubble-image.other { + border-radius: 12rpx 24rpx 24rpx 24rpx; +} + +.chat-bubble-image.me { + border-radius: 24rpx 12rpx 24rpx 24rpx; +} + +.message-image { + width: 100%; + min-width: 200rpx; + max-width: 400rpx; + display: block; +} + +/* 语音消息气泡 */ +.chat-bubble.voice { + display: flex; + align-items: center; + gap: 16rpx; + min-width: 160rpx; + padding: 24rpx 32rpx; +} + +.chat-bubble.voice.other { + flex-direction: row; +} + +.chat-bubble.voice.me { + flex-direction: row-reverse; +} + +.voice-waves { + display: flex; + align-items: center; + gap: 6rpx; + height: 40rpx; +} + +.voice-wave-bar { + width: 6rpx; + height: 20rpx; + border-radius: 3rpx; + background: #9CA3AF; +} + +.chat-bubble.voice.me .voice-wave-bar { + background: rgba(255, 255, 255, 0.7); +} + +.voice-wave-bar:nth-child(1) { height: 16rpx; } +.voice-wave-bar:nth-child(2) { height: 28rpx; } +.voice-wave-bar:nth-child(3) { height: 20rpx; } + +/* 语音播放动画 */ +.chat-bubble.voice.playing .voice-wave-bar { + animation: voiceWave 0.8s ease-in-out infinite; +} + +.chat-bubble.voice.playing .voice-wave-bar:nth-child(1) { animation-delay: 0s; } +.chat-bubble.voice.playing .voice-wave-bar:nth-child(2) { animation-delay: 0.2s; } +.chat-bubble.voice.playing .voice-wave-bar:nth-child(3) { animation-delay: 0.4s; } + +@keyframes voiceWave { + 0%, 100% { transform: scaleY(1); } + 50% { transform: scaleY(1.8); } +} + +.voice-duration { + font-size: 28rpx; + color: #6B7280; +} + +.chat-bubble.voice.me .voice-duration { + color: rgba(255, 255, 255, 0.9); +} + +/* 语音识别文字 */ +.voice-recognized-text { + margin-top: 8rpx; + padding: 12rpx 16rpx; + background: rgba(0, 0, 0, 0.03); + border-radius: 12rpx; + max-width: 100%; +} + +.voice-recognized-text .recognizing-hint { + font-size: 24rpx; + color: #9CA3AF; +} + +.voice-recognized-text .recognized-text { + font-size: 26rpx; + color: #6B7280; + line-height: 1.5; + word-break: break-all; +} + +/* 礼物消息 */ +.gift-message { + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.gift-message.me { + align-items: flex-end; +} + +/* 确保用户消息内容右对齐 */ +.message-content.me .gift-message { + align-items: flex-end; +} + +.gift-message-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 8rpx; + padding: 24rpx 32rpx; + background: linear-gradient(135deg, #FDF2F8 0%, #FCE7F3 100%); + border-radius: 24rpx; + box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1); +} + +.gift-message-image { + width: 80rpx; + height: 80rpx; +} + +.gift-message-text { + font-size: 26rpx; + color: #914584; +} + +/* 正在输入动画 */ +.chat-bubble.typing { + display: flex; + gap: 12rpx; + padding: 28rpx 40rpx; +} + +.typing-dot { + width: 16rpx; + height: 16rpx; + background: #9CA3AF; + border-radius: 50%; + animation: typing 1.4s infinite ease-in-out; +} + +.typing-dot:nth-child(1) { animation-delay: 0s; } +.typing-dot:nth-child(2) { animation-delay: 0.2s; } +.typing-dot:nth-child(3) { animation-delay: 0.4s; } + +@keyframes typing { + 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; } + 30% { transform: translateY(-12rpx); opacity: 1; } +} + +/* 底部占位 - 为最后一条消息留出空间 */ +.chat-bottom-space { + height: 40rpx; +} + +/* 底部输入区域 */ +.bottom-input-area { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: #FFFFFF; + border-top: 2rpx solid #F3F4F6; + box-shadow: 0 -10rpx 40rpx rgba(0, 0, 0, 0.03); + z-index: 100; + padding-bottom: env(safe-area-inset-bottom); +} + +.input-container { + display: flex; + align-items: center; + gap: 16rpx; + padding: 24rpx 32rpx; + padding-bottom: 16rpx; +} + +/* ==================== Figma设计样式 - 底部输入区域 ==================== */ + +/* Figma输入容器 */ +.figma-input-container { + display: flex; + align-items: center; + gap: 16rpx; + padding: 24rpx 20rpx; + padding-bottom: 20rpx; +} + +/* Figma语音按钮 - 40x40px 灰色圆形 */ +.figma-voice-btn { + width: 80rpx; + height: 80rpx; + background: #F3F4F6; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +/* Figma按钮图标 */ +.figma-btn-icon { + width: 80rpx; + height: 80rpx; +} + +/* Figma输入框 - 234x48px 浅灰背景 圆角16px */ +.figma-input-wrap { + flex: 1; + background: #F9FAFB; + border: 2rpx solid #F3F4F6; + border-radius: 32rpx; + padding: 0 32rpx; + height: 96rpx; + display: flex; + align-items: center; +} + +.figma-text-input { + width: 100%; + height: 100%; + font-size: 36rpx; + color: #101828; + font-family: Arial, sans-serif; +} + +.figma-input-placeholder { + color: rgba(10, 10, 10, 0.5); + font-size: 36rpx; + font-family: Arial, sans-serif; +} + +.figma-input-placeholder.warning { + color: #FF6B6B; +} + +/* Figma表情按钮 - 40x40px 灰色圆形 */ +.figma-emoji-btn { + width: 80rpx; + height: 80rpx; + background: transparent; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.figma-emoji-btn.active { + background: #E9D5FF; +} + +/* Figma发送按钮 */ +.figma-send-btn { + width: 80rpx; + height: 80rpx; + background: #914584; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.figma-send-btn .figma-btn-icon { + width: 44rpx; + height: 44rpx; +} + +/* Figma+号按钮 - 40x40px 粉色圆形背景 #FDF2F8 */ +.figma-add-btn { + width: 80rpx; + height: 80rpx; + background: transparent; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.figma-add-btn.active { + background: #FCE7F3; +} + +/* 语音按钮 */ +.voice-btn { + width: 80rpx; + height: 80rpx; + background: #F3F4F6; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.voice-icon { + width: 48rpx; + height: 48rpx; +} + +/* 输入框 */ +.input-wrap { + flex: 1; + background: #F9FAFB; + border: 2rpx solid #F3F4F6; + border-radius: 32rpx; + padding: 0 32rpx; + height: 96rpx; + display: flex; + align-items: center; +} + +.text-input { + width: 100%; + height: 100%; + font-size: 36rpx; + color: #101828; +} + +.input-placeholder { + color: rgba(10, 10, 10, 0.5); + font-size: 36rpx; +} + +/* 表情按钮 */ +.emoji-btn { + width: 80rpx; + height: 80rpx; + background: #F3F4F6; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.emoji-icon { + width: 48rpx; + height: 48rpx; +} + +/* 更多按钮 */ +.add-btn { + width: 80rpx; + height: 80rpx; + background: #F3F4F6; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.add-icon { + width: 48rpx; + height: 48rpx; +} + +/* 表情面板和更多面板显示时,移除底部安全区域 */ +.bottom-input-area.panel-open { + padding-bottom: 0; +} + +/* 语音录制按钮 */ +.voice-record-btn { + flex: 1; + background: #F3F4F6; + border: 2rpx solid #E5E7EB; + border-radius: 32rpx; + height: 96rpx; + display: flex; + align-items: center; + justify-content: center; + font-size: 32rpx; + font-weight: 500; + color: #374151; + transition: all 0.15s; +} + +.voice-record-btn:active, +.voice-record-btn.recording { + background: #E5E7EB; + transform: scale(0.98); +} + +/* 录音提示浮层 */ +.voice-recording-mask { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.3); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; +} + +.voice-recording-popup { + width: 320rpx; + height: 320rpx; + background: rgba(0, 0, 0, 0.8); + border-radius: 32rpx; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 24rpx; +} + +.voice-recording-popup.cancel { + background: rgba(220, 38, 38, 0.9); +} + +.cancel-icon { + width: 80rpx; + height: 80rpx; + filter: brightness(0) invert(1); +} + +.voice-wave { + display: flex; + align-items: center; + justify-content: center; + gap: 12rpx; + height: 100rpx; +} + +.wave-bar { + width: 12rpx; + height: 40rpx; + background: #22C55E; + border-radius: 6rpx; + animation: wave 0.8s ease-in-out infinite; +} + +.wave-bar:nth-child(1) { animation-delay: 0s; height: 40rpx; } +.wave-bar:nth-child(2) { animation-delay: 0.1s; height: 60rpx; } +.wave-bar:nth-child(3) { animation-delay: 0.2s; height: 80rpx; } +.wave-bar:nth-child(4) { animation-delay: 0.3s; height: 60rpx; } +.wave-bar:nth-child(5) { animation-delay: 0.4s; height: 40rpx; } + +@keyframes wave { + 0%, 100% { transform: scaleY(1); } + 50% { transform: scaleY(1.5); } +} + +.voice-tip { + font-size: 28rpx; + color: #FFFFFF; +} + +.voice-duration-tip { + font-size: 48rpx; + font-weight: 700; + color: #FFFFFF; +} + +/* 发送按钮 */ +.send-btn { + width: 80rpx; + height: 80rpx; + background: #914584; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.send-icon { + width: 44rpx; + height: 44rpx; +} + +/* 按钮激活状态 */ +.voice-btn.active, +.emoji-btn.active, +.add-btn.active { + background: #E9D5FF; +} + +/* 表情面板 */ +.emoji-panel { + background: #FFFFFF; + border-top: 2rpx solid #F3F4F6; +} + +.emoji-scroll { + height: 480rpx; + padding: 24rpx; + box-sizing: border-box; +} + +.emoji-grid { + display: flex; + flex-wrap: wrap; +} + +.emoji-item { + width: 12.5%; + height: 88rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.emoji-text { + font-size: 56rpx; + line-height: 1; +} + +.emoji-item:active { + background: #F3F4F6; + border-radius: 16rpx; +} + +/* 表情面板底部操作 */ +.emoji-actions { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 24rpx; + padding: 16rpx 32rpx; + border-top: 2rpx solid #F3F4F6; + padding-bottom: calc(16rpx + env(safe-area-inset-bottom)); + background: #FFFFFF; +} + +.emoji-delete { + width: 80rpx; + height: 72rpx; + background: #F3F4F6; + border-radius: 12rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.delete-icon { + width: 40rpx; + height: 40rpx; + transform: rotate(180deg); +} + +.emoji-send { + width: 120rpx; + height: 72rpx; + background: #E5E7EB; + border-radius: 12rpx; + display: flex; + align-items: center; + justify-content: center; + font-size: 28rpx; + color: #9CA3AF; +} + +.emoji-send.active { + background: #914584; + color: #FFFFFF; +} + +/* 更多功能面板 */ +.more-panel { + background: #F5F5F5; + border-top: 2rpx solid #E5E7EB; +} + +.more-panel-content { + padding: 40rpx 32rpx 24rpx; +} + +.more-grid { + display: flex; + justify-content: space-between; +} + +/* AI角色聊天:3个图标居中显示(Figma设计样式) */ +.more-grid.ai-chat-grid { + justify-content: center; + gap: 90rpx; + padding: 0 48rpx; +} + +.more-grid.second-row { + margin-top: 40rpx; +} + +.more-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 16rpx; + width: 25%; +} + +.more-icon-wrap { + width: 112rpx; + height: 112rpx; + background: #FFFFFF; + border-radius: 24rpx; + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +/* Figma设计样式 - 图标已包含白色圆角背景和阴影 */ +.more-icon-wrap.figma-style { + width: 128rpx; + height: 128rpx; + background: transparent; + border-radius: 0; + box-shadow: none; +} + +/* Figma图标样式 - 完整显示PNG图标(已包含背景和阴影) */ +.figma-action-icon { + width: 128rpx; + height: 128rpx; +} + +.new-tag { + position: absolute; + top: -8rpx; + right: -8rpx; + background: #FF6B35; + color: #FFFFFF; + font-size: 18rpx; + font-weight: 700; + padding: 4rpx 10rpx; + border-radius: 8rpx; + line-height: 1; +} + +.more-icon-img { + width: 56rpx; + height: 56rpx; +} + +.more-text { + font-size: 28rpx; + font-weight: 700; + color: #4A5565; +} + +/* 相册图标 - CSS绘制 */ +.album-icon { + width: 48rpx; + height: 40rpx; + position: relative; +} + +.album-frame { + width: 48rpx; + height: 40rpx; + border: 4rpx solid #914584; + border-radius: 6rpx; + position: absolute; + top: 0; + left: 0; + box-sizing: border-box; +} + +.album-sun { + width: 10rpx; + height: 10rpx; + background: #914584; + border-radius: 50%; + position: absolute; + top: 8rpx; + left: 8rpx; +} + +.album-mountain { + width: 0; + height: 0; + border-left: 12rpx solid transparent; + border-right: 12rpx solid transparent; + border-bottom: 14rpx solid #914584; + position: absolute; + bottom: 6rpx; + left: 12rpx; +} + +/* 常用语图标 - CSS绘制 */ +.quick-reply-icon { + width: 48rpx; + height: 40rpx; + position: relative; +} + +.reply-bubble1 { + width: 32rpx; + height: 22rpx; + border: 4rpx solid #914584; + border-radius: 6rpx; + position: absolute; + top: 0; + left: 0; + box-sizing: border-box; +} + +.reply-bubble2 { + width: 32rpx; + height: 22rpx; + border: 4rpx solid #914584; + border-radius: 6rpx; + position: absolute; + bottom: 0; + right: 0; + box-sizing: border-box; + background: #FFFFFF; +} + +/* 红包图标 - CSS绘制 */ +.red-packet-icon { + width: 40rpx; + height: 52rpx; + position: relative; +} + +.packet-body { + width: 40rpx; + height: 52rpx; + background: #E53935; + border-radius: 6rpx; + position: absolute; + top: 0; + left: 0; +} + +.packet-top { + width: 40rpx; + height: 20rpx; + background: #C62828; + border-radius: 6rpx 6rpx 0 0; + position: absolute; + top: 0; + left: 0; +} + +.packet-circle { + width: 16rpx; + height: 16rpx; + background: #FFD54F; + border-radius: 50%; + position: absolute; + top: 12rpx; + left: 12rpx; +} + +.more-panel-safe { + height: env(safe-area-inset-bottom); + background: #F5F5F5; +} + +/* 录音提示浮层 */ +.voice-recording-mask { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.3); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; +} + +.voice-recording-popup { + width: 320rpx; + height: 320rpx; + background: rgba(0, 0, 0, 0.8); + border-radius: 32rpx; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 32rpx; +} + +.voice-recording-popup.cancel { + background: rgba(220, 38, 38, 0.9); +} + +.voice-wave { + display: flex; + align-items: center; + justify-content: center; + gap: 12rpx; + height: 100rpx; +} + +.wave-bar { + width: 12rpx; + height: 40rpx; + background: #FFFFFF; + border-radius: 6rpx; + animation: wave 0.8s ease-in-out infinite; +} + +.wave-bar:nth-child(1) { animation-delay: 0s; height: 40rpx; } +.wave-bar:nth-child(2) { animation-delay: 0.1s; height: 60rpx; } +.wave-bar:nth-child(3) { animation-delay: 0.2s; height: 80rpx; } +.wave-bar:nth-child(4) { animation-delay: 0.3s; height: 60rpx; } +.wave-bar:nth-child(5) { animation-delay: 0.4s; height: 40rpx; } + +@keyframes wave { + 0%, 100% { transform: scaleY(1); } + 50% { transform: scaleY(1.5); } +} + +.voice-tip { + font-size: 28rpx; + color: #FFFFFF; +} + +/* 礼物选择弹窗 */ +.gift-popup-mask { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 300; + display: flex; + align-items: flex-end; +} + +.gift-popup { + width: 100%; + background: #FFFFFF; + border-radius: 32rpx 32rpx 0 0; + max-height: 80vh; + display: flex; + flex-direction: column; +} + +.gift-popup-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 32rpx; + border-bottom: 2rpx solid #F3F4F6; +} + +.gift-popup-title { + font-size: 36rpx; + font-weight: 700; + color: #101828; +} + +.gift-popup-close { + width: 56rpx; + height: 56rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.close-icon { + width: 40rpx; + height: 40rpx; +} + +.gift-list-scroll { + flex: 1; + max-height: 500rpx; + padding: 24rpx; +} + +.gift-grid { + display: flex; + flex-wrap: wrap; + gap: 24rpx; +} + +.gift-item { + width: calc(25% - 18rpx); + display: flex; + flex-direction: column; + align-items: center; + gap: 8rpx; + padding: 16rpx 8rpx; + border-radius: 16rpx; + border: 2rpx solid transparent; + transition: all 0.2s; +} + +.gift-item.selected { + background: #FDF2F8; + border-color: #914584; +} + +.gift-image { + width: 80rpx; + height: 80rpx; +} + +.gift-name { + font-size: 24rpx; + color: #374151; + text-align: center; +} + +.gift-price { + display: flex; + align-items: center; + gap: 4rpx; + font-size: 22rpx; + color: #914584; +} + +.price-icon { + width: 24rpx; + height: 24rpx; +} + +.gift-popup-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 24rpx 32rpx; + padding-bottom: calc(24rpx + env(safe-area-inset-bottom)); + border-top: 2rpx solid #F3F4F6; +} + +.gift-balance { + display: flex; + align-items: center; + gap: 8rpx; +} + +.balance-label { + font-size: 28rpx; + color: #6B7280; +} + +.balance-icon { + width: 32rpx; + height: 32rpx; +} + +.balance-value { + font-size: 32rpx; + font-weight: 700; + color: #914584; +} + +.gift-send-btn { + width: 200rpx; + height: 80rpx; + background: #E5E7EB; + border-radius: 40rpx; + display: flex; + align-items: center; + justify-content: center; + font-size: 32rpx; + font-weight: 700; + color: #9CA3AF; +} + +.gift-send-btn.active { + background: #914584; + color: #FFFFFF; +} + +/* 免费畅聊提醒条 */ +.free-chat-bar { + position: fixed; + left: 0; + right: 0; + z-index: 99; + padding: 16rpx 32rpx; + background: #FFF5F5; + border-bottom: 2rpx solid #FFE4E4; + box-shadow: 0 2rpx 10rpx rgba(255, 77, 79, 0.05); +} + +.free-chat-content { + display: flex; + align-items: center; + justify-content: space-between; +} + +.free-chat-left { + display: flex; + align-items: center; + gap: 12rpx; +} + +.clock-icon { + width: 32rpx; + height: 32rpx; +} + +.free-chat-label { + font-size: 26rpx; + color: #FF4D4F; + font-weight: 500; +} + +.free-chat-time { + font-size: 28rpx; + color: #FF4D4F; + font-weight: bold; +} + +/* 免费畅聊选项样式 */ +.free-chat-option { + background: #F0FFF4; + border: 2rpx solid #C6F6D5; + box-shadow: 0 2rpx 4rpx -2rpx rgba(0, 0, 0, 0.1), 0 2rpx 6rpx rgba(0, 0, 0, 0.1); +} + +.free-icon { + background: linear-gradient(135deg, #F0FFF4 0%, #C6F6D5 100%); +} + +.free-btn { + background: #38A169; +} + +.free-btn text { + color: #fff; +} + +/* ==================== 解锁弹窗样式(与首页一致) ==================== */ + +/* 输入框警告样式 */ +.input-placeholder.warning { + color: #FF6B6B; +} + +/* 弹窗遮罩 */ +.unlock-popup-mask { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 1001; + display: flex; + align-items: center; + justify-content: center; +} + +/* 弹窗主体 */ +.unlock-popup { + width: 680rpx; + background: #F8F9FC; + border-radius: 48rpx; + overflow: hidden; + position: relative; + box-shadow: 0 50rpx 100rpx -24rpx rgba(0, 0, 0, 0.25); + animation: unlockPopupIn 0.3s ease-out; +} + +@keyframes unlockPopupIn { + from { + opacity: 0; + transform: scale(0.9); + } + to { + opacity: 1; + transform: scale(1); + } +} + +/* 关闭按钮 */ +.unlock-popup-close { + position: absolute; + top: 32rpx; + right: 32rpx; + width: 32rpx; + height: 32rpx; + display: flex; + justify-content: center; + align-items: center; + opacity: 0.7; + z-index: 10; +} + +.unlock-popup-close text { + font-size: 40rpx; + color: #0A0A0A; + line-height: 1; + font-weight: 300; +} + +/* 顶部白色区域 */ +.unlock-popup-header { + background: #fff; + padding: 48rpx 48rpx 40rpx; + border-radius: 0 0 60rpx 60rpx; + box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + align-items: center; +} + +/* 头像容器 */ +.unlock-avatar-container { + position: relative; + width: 192rpx; + height: 192rpx; + margin-bottom: 32rpx; +} + +.unlock-avatar-wrap { + width: 192rpx; + height: 192rpx; + border-radius: 50%; + overflow: hidden; + border: 4rpx solid #fff; + box-shadow: 0 20rpx 30rpx -6rpx rgba(0, 0, 0, 0.1), 0 8rpx 12rpx -4rpx rgba(0, 0, 0, 0.1); +} + +.unlock-avatar { + width: 100%; + height: 100%; +} + +/* 锁图标 */ +.unlock-lock-icon { + position: absolute; + bottom: -8rpx; + right: -8rpx; + width: 56rpx; + height: 56rpx; + background: #fff; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4rpx 12rpx -2rpx rgba(0, 0, 0, 0.1), 0 8rpx 12rpx -2rpx rgba(0, 0, 0, 0.1); +} + +.unlock-lock-icon image { + width: 32rpx; + height: 32rpx; +} + +/* 标题 */ +.unlock-title { + text-align: center; + font-size: 42rpx; + font-weight: 900; + color: #101828; + line-height: 1.25; + letter-spacing: -0.5rpx; +} + +.unlock-title .highlight { + color: #914584; +} + +/* 选项区域 */ +.unlock-options { + padding: 40rpx 40rpx 48rpx; + display: flex; + flex-direction: column; + gap: 24rpx; +} + +/* 选项卡通用样式 */ +.unlock-option-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 32rpx; + height: 140rpx; + border-radius: 28rpx; +} + +.option-left { + display: flex; + align-items: center; + gap: 28rpx; +} + +/* 图标容器 */ +.option-icon { + width: 88rpx; + height: 88rpx; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +/* 爱心图标 */ +.hearts-icon { + background: linear-gradient(135deg, #FEF2F2 0%, #FFE2E2 100%); + box-shadow: inset 0 4rpx 8rpx rgba(0, 0, 0, 0.05); +} + +.hearts-icon image { + width: 44rpx; + height: 44rpx; +} + +/* 金钱图标 */ +.money-icon { + background: rgba(255, 255, 255, 0.2); +} + +.money-icon .money-symbol { + font-size: 48rpx; + font-weight: 900; + color: #fff; +} + +/* 选项信息 */ +.option-info { + display: flex; + flex-direction: column; + gap: 0; +} + +.option-title { + font-size: 40rpx; + font-weight: 900; + color: #101828; + line-height: 1.5; + letter-spacing: -0.5rpx; +} + +.option-desc { + font-size: 30rpx; + font-weight: 700; + color: #6A7282; + line-height: 1.5; +} + +.option-info.light .option-title { + color: #fff; +} + +.option-info.light .option-desc { + color: rgba(255, 255, 255, 0.95); +} + +/* 按钮 */ +.option-btn { + padding: 16rpx 40rpx; + border-radius: 100rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.option-btn text { + font-size: 36rpx; + font-weight: 900; + letter-spacing: -0.5rpx; +} + +/* 爱心选项 */ +.hearts-option { + background: #fff; + border: 2rpx solid #F3F4F6; + box-shadow: 0 2rpx 4rpx -2rpx rgba(0, 0, 0, 0.1), 0 2rpx 6rpx rgba(0, 0, 0, 0.1); +} + +.hearts-btn { + background: #F3F4F6; +} + +.hearts-btn text { + color: #4A5565; +} + +/* 现金选项 */ +.money-option { + background: linear-gradient(180deg, #914584 0%, #7A3A6F 100%); + box-shadow: 0 8rpx 12rpx -8rpx rgba(243, 232, 255, 1), 0 20rpx 30rpx -6rpx rgba(243, 232, 255, 1); +} + +.money-btn { + background: #fff; + box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.1); +} + +.money-btn text { + color: #914584; +} + +/* 暂不需要 */ +.unlock-cancel { + text-align: center; + padding: 20rpx 0 0; +} + +.unlock-cancel text { + font-size: 32rpx; + font-weight: 700; + color: #99A1AF; + letter-spacing: 0.5rpx; +} diff --git a/pages/chat/chat.js b/pages/chat/chat.js new file mode 100644 index 0000000..092869c --- /dev/null +++ b/pages/chat/chat.js @@ -0,0 +1,714 @@ +// pages/chat/chat.js +// 消息列表页面 - 显示会话列表 + +const app = getApp() +const api = require('../../utils/api') +const util = require('../../utils/util') +const proactiveMessage = require('../../utils/proactiveMessage') + +Page({ + data: { + // 系统消息(静态) + systemMessages: [ + { + id: 'sys-1', + name: '推广收益', + type: 'system', + icon: '/images/icon-trending-up.png', + gradient: 'pink', + preview: '您的好友开通了会员,您获得收益!', + time: '刚刚', + unread: 0 + }, + { + id: 'sys-2', + name: '系统通知', + type: 'system', + icon: '/images/icon-bell.png', + gradient: 'purple', + preview: '欢迎来到心伴俱乐部', + time: '', + unread: 0 + } + ], + + // AI会话列表 + conversations: [], + + // 总未读消息数 + totalUnread: 0, + + // 加载状态 + loading: true, + error: null, + auditStatus: 0, + + // 免费畅聊相关 + freeTime: null, + countdownText: '' + }, + + onLoad() { + this.loadConversations() + }, + + onShow() { + wx.hideTabBar({ animation: false }) + const app = getApp() + this.setData({ + auditStatus: app.globalData.auditStatus + }) + + // 检查免费畅聊时间 + this.checkFreeTime() + + // 每次显示时刷新列表 + // 增加延迟,确保标记已读API有时间完成(从聊天详情页返回时) + if (!this.data.loading) { + setTimeout(() => { + this.loadConversations() + }, 500) // 增加到500ms,确保标记已读API完成 + } + }, + + onPullDownRefresh() { + this.checkFreeTime() + this.loadConversations().then(() => { + wx.stopPullDownRefresh() + }) + }, + + /** + * 检查免费畅聊时间 + */ + async checkFreeTime() { + if (!app.globalData.isLoggedIn) return + + try { + const res = await api.chat.getFreeTime() + console.log('[chat] 免费畅聊时间响应:', res) + if (res.success && res.data) { + this.setData({ + freeTime: res.data + }) + + if (res.data.isActive && res.data.remainingSeconds > 0) { + this.startCountdown(res.data.remainingSeconds) + } else { + this.stopCountdown() + } + } + } catch (err) { + console.error('[chat] 获取免费畅聊时间失败:', err) + } + }, + + /** + * 开始倒计时 + */ + startCountdown(seconds) { + this.stopCountdown() + + let remaining = seconds + this.setData({ + countdownText: this.formatSeconds(remaining) + }) + + this.countdownTimer = setInterval(() => { + remaining-- + if (remaining <= 0) { + this.stopCountdown() + this.setData({ + 'freeTime.isActive': false, + 'freeTime.remainingSeconds': 0 + }) + } else { + this.setData({ + countdownText: this.formatSeconds(remaining) + }) + } + }, 1000) + }, + + /** + * 停止倒计时 + */ + stopCountdown() { + if (this.countdownTimer) { + clearInterval(this.countdownTimer) + this.countdownTimer = null + } + this.setData({ countdownText: '' }) + }, + + /** + * 格式化秒数为分钟(向上取整) + */ + formatSeconds(s) { + const m = Math.ceil(s / 60) + return m + }, + + onHide() { + this.stopCountdown() + }, + + onUnload() { + this.stopCountdown() + }, + + /** + * 加载会话列表 + */ + async loadConversations() { + // 检查登录状态 + if (!app.globalData.isLoggedIn) { + this.setData({ + conversations: [], + loading: false + }) + return + } + + this.setData({ loading: true, error: null }) + + try { + // 并行获取会话列表和主动推送消息 + const [convRes, proactiveMessages] = await Promise.all([ + api.chat.getConversations(), + this.getProactiveMessagesForList() + ]) + + console.log('[chat] 会话列表API响应:', JSON.stringify(convRes)) + console.log('[chat] 主动推送消息:', JSON.stringify(proactiveMessages)) + + if (convRes.success && convRes.data) { + // 转换数据格式 + let conversations = convRes.data.map(conv => this.transformConversation(conv)) + console.log('[chat] 转换后的会话数量:', conversations.length) + + // 将主动推送消息合并到会话列表 + if (proactiveMessages && proactiveMessages.length > 0) { + console.log('[chat] 开始合并主动推送消息,数量:', proactiveMessages.length) + conversations = this.mergeProactiveMessages(conversations, proactiveMessages) + console.log('[chat] 合并后的会话数量:', conversations.length) + } + + // 按时间排序(最新的在前) + conversations.sort((a, b) => { + return new Date(b.updatedAt) - new Date(a.updatedAt) + }) + + // 计算总未读消息数 + const totalUnread = conversations.reduce((sum, conv) => sum + (conv.unread || 0), 0) + + this.setData({ + conversations, + totalUnread, + loading: false + }) + + console.log('[chat] 最终会话列表:', conversations.map(c => ({ id: c.id, name: c.name, unread: c.unread, isProactive: c.isProactive }))) + } else { + throw new Error(convRes.message || '加载失败') + } + } catch (err) { + console.error('加载会话列表失败', err) + + // 如果是401错误,不显示错误提示,因为会话列表会被清空 + if (err.code === 401) { + this.setData({ + conversations: [], + loading: false + }) + return + } + + this.setData({ + loading: false, + error: err.message || '加载失败' + }) + } + }, + + /** + * 转换会话数据格式 + * @param {object} conv - 后端会话数据 + */ + transformConversation(conv) { + const character = conv.character || {} + const config = require('../../config/index') + + // 处理头像URL - 如果是相对路径,拼接服务器地址 + let avatarUrl = character.avatar || '' + if (avatarUrl && avatarUrl.startsWith('/characters/')) { + const baseUrl = config.API_BASE_URL.replace('/api', '') + avatarUrl = baseUrl + avatarUrl + } + + // 处理预览消息 - 优先显示最后消息,否则显示默认提示 + let preview = conv.last_message + if (!preview || preview.trim() === '') { + preview = '点击开始聊天~' + } + + return { + id: conv.id, + characterId: conv.character_id || conv.target_id, + name: character.name || 'AI助手', + type: 'ai', + avatar: avatarUrl || 'https://ai-c.maimanji.com/images/default-avatar.png', + preview: preview, + time: util.formatRelativeTime(conv.updated_at), + updatedAt: conv.updated_at, + unread: conv.unread_count || 0, + isOnline: true + } + }, + + /** + * 获取主动推送消息列表(用于合并到会话列表) + * 直接调用API获取,不依赖缓存 + */ + async getProactiveMessagesForList() { + try { + // 直接调用API获取待推送消息 + const res = await api.proactiveMessage.getPending() + console.log('[chat] 主动推送消息API响应:', JSON.stringify(res)) + + if (res.success && res.data && Array.isArray(res.data)) { + console.log('[chat] 获取到主动推送消息:', res.data.length, '条') + return res.data + } + + console.log('[chat] 主动推送消息API返回空或格式错误') + return [] + } catch (err) { + console.log('[chat] 获取主动推送消息失败', err) + return [] + } + }, + + /** + * 将主动推送消息合并到会话列表 + * @param {Array} conversations - 现有会话列表 + * @param {Array} proactiveMessages - 主动推送消息列表 + */ + mergeProactiveMessages(conversations, proactiveMessages) { + if (!proactiveMessages || proactiveMessages.length === 0) { + console.log('[chat] 没有主动推送消息需要合并') + return conversations + } + + const config = require('../../config/index') + const baseUrl = config.API_BASE_URL.replace('/api', '') + + console.log('[chat] 开始合并主动推送消息,消息数:', proactiveMessages.length) + + // 遍历主动推送消息 + proactiveMessages.forEach((msg, index) => { + console.log(`[chat] 处理第${index + 1}条消息:`, { + character_id: msg.character_id, + character_name: msg.character_name, + content: msg.content?.substring(0, 20) + '...' + }) + + // 查找是否已有该角色的会话 + const existingConvIndex = conversations.findIndex(c => { + // 兼容不同的ID格式(字符串和数字) + return String(c.characterId) === String(msg.character_id) + }) + + if (existingConvIndex >= 0) { + // 已有会话:只更新预览消息和未读数,不修改时间(避免列表位置跳动) + const existingConv = conversations[existingConvIndex] + console.log(`[chat] 找到已有会话:`, existingConv.name, '更新消息(保持原有时间排序)') + + existingConv.preview = msg.content + existingConv.unread = (existingConv.unread || 0) + 1 + // 注意:不修改 updatedAt 和 time,保持会话原有的排序位置 + existingConv.isProactive = true // 标记为主动推送消息 + } else { + // 没有会话:创建新的会话项 + console.log(`[chat] 创建新会话:`, msg.character_name) + + let avatarUrl = msg.character_logo || '' + if (avatarUrl && avatarUrl.startsWith('/characters/')) { + avatarUrl = baseUrl + avatarUrl + } + + const newConv = { + id: `proactive_${msg.character_id}`, + characterId: msg.character_id, + name: msg.character_name || 'AI助手', + type: 'ai', + avatar: avatarUrl || 'https://ai-c.maimanji.com/images/default-avatar.png', + preview: msg.content, + time: util.formatRelativeTime(msg.sent_at), + updatedAt: msg.sent_at, + unread: 1, + isOnline: true, + isProactive: true // 标记为主动推送消息 + } + + conversations.push(newConv) + console.log(`[chat] 新会话已添加:`, newConv.name, newConv.id) + } + }) + + console.log('[chat] 合并完成,最终会话数:', conversations.length) + return conversations + }, + + /** + * 点击免费畅聊条 + */ + onFreeChatTap() { + wx.showModal({ + title: '免费畅聊', + content: '您当前拥有免费畅聊特权,可以无消耗与 AI 角色对话。', + showCancel: false, + confirmText: '我知道了' + }) + }, + + /** + * 返回上一页 + */ + goBack() { + wx.navigateBack({ + fail: () => { + wx.switchTab({ url: '/pages/index/index' }) + } + }) + }, + + /** + * 点击消息项 + */ + async onMessageTap(e) { + const { id, type } = e.currentTarget.dataset + + if (type === 'system') { + // 系统消息 + this.handleSystemMessage(id) + } else { + // AI会话 + const conversation = this.data.conversations.find(c => c.id === id) + if (conversation) { + // 处理主动推送消息创建的虚拟会话(ID以proactive_开头) + const isProactiveConv = id.startsWith('proactive_') + const conversationId = isProactiveConv ? '' : id + + // 跳转到聊天详情页(标记已读在详情页onLoad时调用) + wx.navigateTo({ + url: `/pages/chat-detail/chat-detail?id=${conversation.characterId}&conversationId=${conversationId}&name=${encodeURIComponent(conversation.name)}` + }) + } + } + }, + + /** + * 更新本地未读数 + * @param {string} conversationId - 会话ID + * @param {number} unread - 新的未读数 + */ + updateLocalUnread(conversationId, unread) { + const conversations = this.data.conversations.map(conv => { + if (conv.id === conversationId) { + return { ...conv, unread } + } + return conv + }) + + // 重新计算总未读数 + const totalUnread = conversations.reduce((sum, conv) => sum + (conv.unread || 0), 0) + + this.setData({ conversations, totalUnread }) + }, + + /** + * 处理系统消息点击 + */ + handleSystemMessage(id) { + if (id === 'sys-1') { + // 推广收益 + wx.navigateTo({ url: '/pages/commission/commission' }) + } else if (id === 'sys-2') { + // 系统通知 + wx.showToast({ title: '暂无新通知', icon: 'none' }) + } + }, + + /** + * 标记会话已读 + */ + async markAsRead(conversationId) { + try { + await api.chat.markAsRead(conversationId) + + // 更新本地状态 + const conversations = this.data.conversations.map(conv => { + if (conv.id === conversationId) { + return { ...conv, unread: 0 } + } + return conv + }) + + // 重新计算总未读数 + const totalUnread = conversations.reduce((sum, conv) => sum + (conv.unread || 0), 0) + + this.setData({ conversations, totalUnread }) + } catch (err) { + console.log('标记已读失败', err) + } + }, + + /** + * 删除会话(长按) + */ + onMessageLongPress(e) { + const { id, type } = e.currentTarget.dataset + + if (type === 'system') return + + wx.showActionSheet({ + itemList: ['清空聊天记录', '删除会话(从列表移除)'], + success: (res) => { + if (res.tapIndex === 0) { + // 清空聊天记录(保留会话) + this.clearChatHistory(id) + } else if (res.tapIndex === 1) { + // 删除会话(从列表移除) + this.deleteConversation(id) + } + } + }) + }, + + /** + * 滑动开始 + */ + onTouchStart(e) { + this.touchStartX = e.touches[0].clientX + this.touchStartY = e.touches[0].clientY + this.touchStartTime = Date.now() + }, + + /** + * 滑动中 + */ + onTouchMove(e) { + const moveX = e.touches[0].clientX - this.touchStartX + const moveY = e.touches[0].clientY - this.touchStartY + + // 如果垂直滑动大于水平滑动,不处理 + if (Math.abs(moveY) > Math.abs(moveX)) return + }, + + /** + * 滑动结束 + */ + onTouchEnd(e) { + const endX = e.changedTouches[0].clientX + const moveX = endX - this.touchStartX + const index = e.currentTarget.dataset.index + const id = e.currentTarget.dataset.id + + // 先关闭其他已展开的项 + this.closeAllSwipe(index) + + // 左滑超过50px显示删除按钮 + if (moveX < -50) { + this.setSwipeState(index, true) + } else if (moveX > 50) { + // 右滑关闭 + this.setSwipeState(index, false) + } + }, + + /** + * 设置滑动状态 + */ + setSwipeState(index, swiped) { + const conversations = this.data.conversations + if (conversations[index]) { + conversations[index].swiped = swiped + this.setData({ conversations }) + } + }, + + /** + * 关闭所有滑动项(除了指定的) + */ + closeAllSwipe(exceptIndex) { + const conversations = this.data.conversations.map((conv, idx) => { + if (idx !== exceptIndex && conv.swiped) { + return { ...conv, swiped: false } + } + return conv + }) + this.setData({ conversations }) + }, + + /** + * 滑动删除按钮点击 + */ + onSwipeDelete(e) { + const { id, index } = e.currentTarget.dataset + this.deleteConversation(id) + }, + + /** + * 删除会话 + * 完全删除会话,包括聊天记录,会话从列表中移除 + */ + async deleteConversation(conversationId) { + const confirmed = await util.showConfirm({ + title: '删除会话', + content: '确定要删除这个会话吗?会话将从列表中移除,聊天记录也会被清空。' + }) + + if (!confirmed) return + + wx.showLoading({ title: '删除中...' }) + + try { + // 调用后端API删除会话 + const res = await api.chat.deleteConversation(conversationId) + + if (res.success || res.code === 0) { + // 本地删除 + const conversations = this.data.conversations.filter(c => c.id !== conversationId) + this.setData({ conversations }) + util.showSuccess('已删除') + } else { + throw new Error(res.message || '删除失败') + } + } catch (err) { + console.error('删除会话失败:', err) + // 即使API失败,也从本地删除 + const conversations = this.data.conversations.filter(c => c.id !== conversationId) + this.setData({ conversations }) + util.showSuccess('已删除') + } finally { + wx.hideLoading() + } + }, + + /** + * 清空聊天记录 + * 只清空聊天记录,不删除会话 + * 会话仍然显示在消息列表中,只是聊天记录被清空 + */ + async clearChatHistory(conversationId) { + const confirmed = await util.showConfirm({ + title: '清空记录', + content: '确定要清空聊天记录吗?此操作不可恢复。会话仍会保留在列表中。' + }) + + if (!confirmed) return + + // 找到对应的会话,获取角色ID + const conversation = this.data.conversations.find(c => c.id === conversationId) + if (!conversation || !conversation.characterId) { + util.showError('会话信息错误') + return + } + + wx.showLoading({ title: '清空中...' }) + + try { + // 调用后端API清空聊天记录(使用角色ID) + const res = await api.chat.clearChatHistory(conversation.characterId) + + if (res.success || res.code === 0) { + // 更新本地会话列表,清空预览消息 + const conversations = this.data.conversations.map(conv => { + if (conv.id === conversationId) { + return { + ...conv, + preview: '点击开始聊天~', + unread: 0 + } + } + return conv + }) + + this.setData({ conversations }) + util.showSuccess('已清空') + } else { + throw new Error(res.message || '清空失败') + } + } catch (err) { + console.error('清空聊天记录失败:', err) + util.showError('清空失败,请重试') + } finally { + wx.hideLoading() + } + }, + + /** + * 切换Tab - 需要登录的页面检查登录状态 + */ + switchTab(e) { + const path = e.currentTarget.dataset.path + if (path) { + const app = getApp() + + // 消息页面需要登录 + if (path === '/pages/chat/chat') { + if (!app.globalData.isLoggedIn) { + wx.navigateTo({ + url: '/pages/login/login?redirect=' + encodeURIComponent(path) + }) + return + } + } + wx.switchTab({ url: path }) + } + }, + + /** + * 需要登录时的回调 + */ + onAuthRequired() { + wx.showModal({ + title: '提示', + content: '请先登录后查看消息', + confirmText: '去登录', + confirmColor: '#b06ab3', + success: (res) => { + if (res.confirm) { + app.wxLogin().then(() => { + this.loadConversations() + }).catch(err => { + util.showError('登录失败') + }) + } + } + }) + }, + + /** + * 检查AI角色主动推送消息 + */ + async checkProactiveMessages() { + if (!app.globalData.isLoggedIn) { + return + } + + try { + const messages = await proactiveMessage.checkAndShowMessages({ + onNewMessages: (msgs) => { + // 收到新消息时刷新会话列表 + this.loadConversations() + } + }) + + console.log('[chat] 主动推送消息检查完成,消息数:', messages.length) + } catch (err) { + console.log('[chat] 检查主动推送消息失败', err) + } + } +}) diff --git a/pages/chat/chat.json b/pages/chat/chat.json new file mode 100644 index 0000000..b16ef3e --- /dev/null +++ b/pages/chat/chat.json @@ -0,0 +1,4 @@ +{ + "navigationStyle": "custom", + "usingComponents": {} +} diff --git a/pages/chat/chat.wxml b/pages/chat/chat.wxml new file mode 100644 index 0000000..c735da2 --- /dev/null +++ b/pages/chat/chat.wxml @@ -0,0 +1,108 @@ + + + + + + 消息 + + + + + + + + + + + + + + + + + + + + {{item.unread}} + + + + {{item.name}} + {{item.time}} + + {{item.preview}} + + + + + + + + + + + + {{item.unread}} + + + + {{item.name}} + {{item.time}} + + {{item.preview}} + + + + + + + 删除 + + + + + + + 加载中... + + + + + 暂无聊天记录 + 去陪伴页面挑选聊天的伙伴吧~ + + + + + + + + 陪伴 + + + + 文娱 + + + + 服务 + + + + + + 消息 + + + + 我的 + + + diff --git a/pages/chat/chat.wxss b/pages/chat/chat.wxss new file mode 100644 index 0000000..00352c0 --- /dev/null +++ b/pages/chat/chat.wxss @@ -0,0 +1,292 @@ +page { + width: 100%; + overflow-x: hidden; + background: #fff; +} + +.page-container { + min-height: 100vh; + display: flex; + flex-direction: column; + width: 100%; + overflow-x: hidden; + background: #fff; +} + +/* 顶部导航栏已移除,改用全局 unified-header */ + +/* 免费畅聊提醒条 (嵌入式) */ +.free-chat-banner { + margin: 20rpx 40rpx 10rpx; + padding: 24rpx 32rpx; + background: #FFF5F5; + border-radius: 20rpx; + border: 2rpx solid #FFE4E4; +} + +.free-chat-banner-content { + display: flex; + align-items: center; + gap: 16rpx; +} + +.banner-clock-icon { + width: 32rpx; + height: 32rpx; +} + +.banner-text { + font-size: 28rpx; + color: #FF4D4F; + font-weight: 500; +} + +/* 消息列表 */ +.message-list { + flex: 1; + padding-top: 174rpx; /* 只留出 header 的位置 */ + padding-bottom: 160rpx; +} + +.message-item { + display: flex; + align-items: flex-start; + padding: 32rpx 40rpx; + border-bottom: 2rpx solid #f3f4f6; + gap: 32rpx; +} + +/* 头像区域 */ +.avatar-section { + position: relative; + flex-shrink: 0; +} + +.avatar-wrapper { + width: 136rpx; + height: 136rpx; + border-radius: 32rpx; + overflow: hidden; +} + +.avatar-wrapper.ai { + border: 2rpx solid #f3f4f6; + box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.1), 0 2rpx 4rpx -2rpx rgba(0, 0, 0, 0.1); +} + +.avatar-wrapper.system { + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 8rpx 12rpx -2rpx rgba(0, 0, 0, 0.1); +} + +.avatar-wrapper.system.pink { + background: linear-gradient(180deg, #ff9a9e 0%, #fecfef 100%); +} + +.avatar-wrapper.system.purple { + background: linear-gradient(180deg, #a18cd1 0%, #fbc2eb 100%); +} + +.avatar-image { + width: 100%; + height: 100%; +} + +.avatar-icon { + width: 56rpx; + height: 56rpx; +} + +.badge { + position: absolute; + top: -16rpx; + right: -8rpx; + min-width: 48rpx; + height: 48rpx; + background: #ff3b30; + border: 2rpx solid #fff; + border-radius: 24rpx; + display: flex; + align-items: center; + justify-content: center; + padding: 0 14rpx; + box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.1), 0 2rpx 4rpx -2rpx rgba(0, 0, 0, 0.1); + font-size: 26rpx; + font-weight: bold; + color: #fff; +} + +/* 内容区域 */ +.message-content { + flex: 1; + min-width: 0; + overflow: hidden; + padding-top: 12rpx; +} + +.message-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 8rpx; +} + +.message-name { + font-size: 36rpx; + font-weight: bold; + color: #101828; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.message-time { + font-size: 26rpx; + color: #99a1af; + flex-shrink: 0; + margin-left: 16rpx; +} + +.message-preview { + font-size: 30rpx; + color: #6a7282; + line-height: 1.6; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; +} + +/* 底部导航栏 - 完全匹配Figma设计 */ +.custom-tabbar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 194rpx; + background: #fff; + display: flex; + align-items: flex-start; + justify-content: space-around; + padding-top: 24rpx; + z-index: 999; + border-top: 2rpx solid #F3F4F6; +} + +.tabbar-item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12rpx; + width: 150rpx; + height: 120rpx; +} + +.tabbar-icon { + width: 68rpx; + height: 68rpx; +} + +.tabbar-text { + font-family: Arial, sans-serif; + font-size: 40rpx; + font-weight: 700; + color: #A58AA5; + line-height: 1; +} + +.tabbar-text.active { + color: #B06AB3; +} + +.message-icon-wrapper { + width: 68rpx; + height: 68rpx; +} + +.message-icon-wrapper .tabbar-icon { + width: 68rpx; + height: 68rpx; +} + + +/* 加载状态 */ +.loading-tip { + display: flex; + justify-content: center; + padding: 40rpx; + color: #99a1af; + font-size: 28rpx; +} + +/* 空状态 */ +.empty-tip { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 120rpx 40rpx; + color: #99a1af; + font-size: 30rpx; +} + +.empty-sub { + font-size: 26rpx; + margin-top: 16rpx; + color: #c0c5ce; +} + +/* 滑动删除样式 */ +.swipe-container { + position: relative; + overflow: hidden; +} + +.swipe-content { + position: relative; + z-index: 2; + background: #fff; + transition: transform 0.2s ease-out; +} + +.swipe-content.swiped { + transform: translateX(-160rpx); +} + +.swipe-actions { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 160rpx; + display: flex; + align-items: stretch; + z-index: 1; + opacity: 0; + transition: opacity 0.2s ease-out; +} + +.swipe-actions.show { + opacity: 1; +} + +.action-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; +} + +.action-btn.delete { + background: linear-gradient(135deg, #ff6b6b 0%, #ee5a5a 100%); +} + +.action-btn text { + color: #fff; + font-size: 28rpx; + font-weight: 500; +} diff --git a/pages/city-activities/city-activities.js b/pages/city-activities/city-activities.js new file mode 100644 index 0000000..f784ae9 --- /dev/null +++ b/pages/city-activities/city-activities.js @@ -0,0 +1,358 @@ +// pages/city-activities/city-activities.js - 同城活动页面 +const api = require('../../utils/api') +const app = getApp() + +Page({ + data: { + statusBarHeight: 44, + navBarHeight: 44, + totalNavHeight: 88, + loading: false, + + // 选中的城市 + selectedCity: '深圳市', + + // 活动列表 + activityList: [], + + // 二维码弹窗 + showQrcodeModal: false, + qrcodeImageUrl: '' // 二维码图片URL,从后端获取 + }, + + onLoad(options) { + // 计算导航栏高度 + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + const menuButton = wx.getMenuButtonBoundingClientRect() + const navBarHeight = menuButton.height + (menuButton.top - statusBarHeight) * 2 + const totalNavHeight = statusBarHeight + navBarHeight + + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight + }) + + // 从文娱首页获取城市信息(通过全局数据或页面参数) + const app = getApp() + // 解码URL参数中的城市名称 + let selectedCity = '深圳市' + if (options.city) { + selectedCity = decodeURIComponent(options.city) + } else if (app.globalData.selectedCity) { + selectedCity = app.globalData.selectedCity + } + + console.log('[city-activities] 接收到的城市:', selectedCity) + this.setData({ selectedCity }) + this.loadActivityList() + }, + + /** + * 页面显示时检查城市是否变化 + */ + onShow() { + const app = getApp() + if (app.globalData.selectedCity && app.globalData.selectedCity !== this.data.selectedCity) { + console.log('[city-activities] 城市已变更:', app.globalData.selectedCity) + this.setData({ selectedCity: app.globalData.selectedCity }) + this.loadActivityList() + } + }, + + /** + * 返回上一页 + */ + onBack() { + wx.navigateBack() + }, + + /** + * 加载活动列表 - 根据categoryName筛选同城活动 + */ + async loadActivityList() { + this.setData({ loading: true }) + + try { + const res = await api.activity.getList({ + city: this.data.selectedCity, + limit: 50 // 获取更多数据用于前端筛选 + }) + + if (res.success && res.data && res.data.list) { + // 前端筛选:只显示categoryName为"同城活动"的活动 + const allActivities = res.data.list + const cityActivities = allActivities.filter(item => item.categoryName === '同城活动') + + // 转换数据格式 + const activityList = cityActivities.map(item => ({ + id: item.id, + title: item.title, + date: this.formatDate(item.activityDate), + location: item.location || '', + venue: item.venue || '', + image: item.coverImage || '/images/activity-default.jpg', + heat: item.heat || 0, // 使用后端返回的热度字段 + isFree: item.priceType === 'free', + price: item.priceText || '', + status: item.status || ((item.current_participants || item.currentParticipants || 0) >= (item.max_participants || item.maxParticipants || 0) && (item.max_participants || item.maxParticipants || 0) > 0 ? 'full' : 'upcoming'), + activityGuideQrcode: item.activityGuideQrcode || item.activity_guide_qrcode || '' + })) + + console.log('[city-activities] 同城活动加载成功,数量:', activityList.length) + this.setData({ activityList }) + } else { + this.setData({ activityList: [] }) + } + } catch (err) { + console.error('加载活动列表失败', err) + wx.showToast({ + title: '加载失败', + icon: 'none' + }) + this.setData({ activityList: [] }) + } finally { + this.setData({ loading: false }) + } + }, + + /** + * 加载模拟数据(降级方案) + */ + loadMockActivities() { + // 使用空数据,等待后端API返回真实数据 + const mockActivities = [] + + this.setData({ activityList: mockActivities }) + }, + + /** + * 格式化日期 + */ + formatDate(dateStr) { + if (!dateStr) return '' + const date = new Date(dateStr) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}年${month}月${day}日` + }, + + /** + * 选择城市 + */ + onCitySelect() { + wx.navigateTo({ + url: `/pages/city-selector/city-selector?current=${encodeURIComponent(this.data.selectedCity)}` + }) + }, + + /** + * 加入同城群 + */ + onJoinCityGroup() { + // TODO: 从后端获取二维码图片URL + // 暂时使用占位图片 + this.setData({ + showQrcodeModal: true, + qrcodeImageUrl: '/images/city-group-qrcode-placeholder.png' // 占位图片 + }) + }, + + /** + * 关闭二维码弹窗 + */ + onCloseQrcodeModal() { + this.setData({ + showQrcodeModal: false + }) + }, + + /** + * 保存二维码 + */ + onSaveQrcode() { + const { qrcodeImageUrl } = this.data + + if (!qrcodeImageUrl) { + wx.showToast({ + title: '二维码加载中', + icon: 'none' + }) + return + } + + wx.showLoading({ title: '保存中...' }) + + // 下载图片到本地 + wx.downloadFile({ + url: qrcodeImageUrl, + success: (res) => { + if (res.statusCode === 200) { + // 保存到相册 + wx.saveImageToPhotosAlbum({ + filePath: res.tempFilePath, + success: () => { + wx.hideLoading() + wx.showToast({ + title: '已保存到相册', + icon: 'success' + }) + this.onCloseQrcodeModal() + }, + fail: (err) => { + wx.hideLoading() + if (err.errMsg.includes('auth deny')) { + wx.showModal({ + title: '需要相册权限', + content: '请在设置中允许访问相册', + confirmText: '去设置', + success: (modalRes) => { + if (modalRes.confirm) { + wx.openSetting() + } + } + }) + } else { + wx.showToast({ + title: '保存失败', + icon: 'none' + }) + } + } + }) + } else { + wx.hideLoading() + wx.showToast({ + title: '下载失败', + icon: 'none' + }) + } + }, + fail: () => { + wx.hideLoading() + wx.showToast({ + title: '下载失败', + icon: 'none' + }) + } + }) + }, + + /** + * 点击活动卡片 + */ + onActivityTap(e) { + const id = e.currentTarget.dataset.id + wx.navigateTo({ + url: `/pages/activity-detail/activity-detail?id=${id}` + }) + }, + + /** + * 立即报名 + */ + onSignUp(e) { + const id = e.currentTarget.dataset.id + const index = e.currentTarget.dataset.index + const activity = this.data.activityList[index] + + if (!app.globalData.isLoggedIn) { + wx.navigateTo({ + url: '/pages/login/login' + }) + return + } + + // 检查活动状态 + if (activity.status === 'full' || activity.status === 'ended') { + const qrCode = activity.activityGuideQrcode || activity.activity_guide_qrcode || this.data.qrcodeImageUrl || 'https://ai-c.maimanji.com/api/common/qrcode?type=group' + this.setData({ + qrcodeImageUrl: qrCode, + showQrcodeModal: true + }) + return + } + + wx.showModal({ + title: '确认报名', + content: '确定要报名参加这个活动吗?', + success: (res) => { + if (res.confirm) { + this.handleSignUp(id, index) + } + } + }) + }, + + /** + * 处理报名 + */ + async handleSignUp(activityId, index) { + try { + wx.showLoading({ title: '报名中...' }) + + const res = await api.activity.signup(activityId) + + wx.hideLoading() + + if (res.success) { + wx.showToast({ + title: '报名成功', + icon: 'success' + }) + + // 刷新列表 + this.loadActivityList() + } else { + // 检查是否需要显示二维码(后端开关关闭或活动已结束) + if (res.code === 'QR_CODE_REQUIRED' || res.error === 'QR_CODE_REQUIRED' || res.code === 'ACTIVITY_ENDED' || res.error === '活动已结束' || res.code === 'ACTIVITY_FULL' || res.error === '活动已满员') { + const activity = this.data.activityList[index] + if (activity.activityGuideQrcode || activity.activity_guide_qrcode) { + this.setData({ qrcodeImageUrl: activity.activityGuideQrcode || activity.activity_guide_qrcode }) + } + this.setData({ + showQrcodeModal: true + }) + if (res.code === 'ACTIVITY_ENDED' || res.error === '活动已结束' || res.code === 'ACTIVITY_FULL' || res.error === '活动已满员') { + const tip = (res.code === 'ACTIVITY_FULL' || res.error === '活动已满员') ? '活动已满员,进群查看更多' : '活动已结束,进群查看更多' + wx.showToast({ title: tip, icon: 'none' }) + } + } else { + wx.showToast({ + title: res.error || '报名失败', + icon: 'none' + }) + } + } + } catch (err) { + wx.hideLoading() + console.error('报名失败', err) + + // 捕获特定错误码以显示二维码 + const isQrRequired = err && (err.code === 'QR_CODE_REQUIRED' || (err.data && err.data.code === 'QR_CODE_REQUIRED')) + const isActivityEnded = err && (err.code === 'ACTIVITY_ENDED' || (err.data && err.data.code === 'ACTIVITY_ENDED') || err.error === '活动已结束') + const isActivityFull = err && (err.code === 'ACTIVITY_FULL' || (err.data && err.data.code === 'ACTIVITY_FULL') || err.error === '活动已满员') + + if (isQrRequired || isActivityEnded || isActivityFull) { + const activity = this.data.activityList[index] + if (activity.activityGuideQrcode || activity.activity_guide_qrcode) { + this.setData({ qrcodeImageUrl: activity.activityGuideQrcode || activity.activity_guide_qrcode }) + } + this.setData({ + showQrcodeModal: true + }) + if (isActivityEnded || isActivityFull) { + const tip = isActivityFull ? '活动已满员,进群查看更多' : '活动已结束,进群查看更多' + wx.showToast({ title: tip, icon: 'none' }) + } + } else { + wx.showToast({ + title: err.error || err.message || '报名失败', + icon: 'none' + }) + } + } + } +}) diff --git a/pages/city-activities/city-activities.json b/pages/city-activities/city-activities.json new file mode 100644 index 0000000..6e5ee5a --- /dev/null +++ b/pages/city-activities/city-activities.json @@ -0,0 +1,7 @@ +{ + "navigationStyle": "custom", + "navigationBarTextStyle": "black", + "usingComponents": { + "app-icon": "../../components/icon/icon" + } +} diff --git a/pages/city-activities/city-activities.wxml b/pages/city-activities/city-activities.wxml new file mode 100644 index 0000000..e1ee6e9 --- /dev/null +++ b/pages/city-activities/city-activities.wxml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + 同城活动 + + + + + + + + + {{selectedCity}}同城群 + + + {{selectedCity}} + + + + + 点击立即加入 + + + + + + 热门活动 + 共 {{activityList.length}} 个活动 + + + + + + + + + + + + {{item.venue}} + + + + + + {{item.title}} + + + + {{item.date}} + + + + {{item.location}} + + + + + {{item.heat}} + + + + + + + {{item.participants}}人已报名 + + + + + + + + + + 暂无活动 + + + + + + + + + + + + + + + + + + + + + 加入{{selectedCity}}活动群 + + + 及时获取第一手活动资讯 + + + + + + + + + 保存二维码 + + + + diff --git a/pages/city-activities/city-activities.wxss b/pages/city-activities/city-activities.wxss new file mode 100644 index 0000000..643d65d --- /dev/null +++ b/pages/city-activities/city-activities.wxss @@ -0,0 +1,479 @@ +/* 同城活动页面样式 - 玫瑰紫版 v3.0 */ +page { + background: linear-gradient(180deg, #E8C3D4 0%, #F5E6ED 100%); +} + +.page-container { + min-height: 100vh; + background: linear-gradient(180deg, #E8C3D4 0%, #F5E6ED 100%); +} + +/* 固定导航栏容器 */ +.nav-container { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 999; + background: rgba(248, 249, 252, 0.75); + backdrop-filter: blur(20rpx) saturate(180%); + -webkit-backdrop-filter: blur(20rpx) saturate(180%); +} + +/* 状态栏 */ +.status-bar { + background: transparent; +} + +/* 导航栏 */ +.nav-bar { + display: flex; + align-items: center; + justify-content: center; + padding: 0 32rpx; + background: transparent; + position: relative; +} + +.nav-back { + position: absolute; + left: 32rpx; + top: 50%; + transform: translateY(-50%); + width: 80rpx; + height: 80rpx; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +.back-icon { + width: 48rpx; + height: 48rpx; +} + +.nav-title { + font-size: 36rpx; + font-weight: 700; + color: #1A1A1A; + line-height: 1; +} + +/* 内容滚动区域 */ +.content-scroll { + height: 100vh; + box-sizing: border-box; +} + +/* 同城群推广卡片 - 玫瑰紫渐变 */ +.city-group-card { + margin: 32rpx; + padding: 32rpx 40rpx; + min-height: 128rpx; + background: linear-gradient(135deg, + rgba(232, 195, 212, 0.6) 0%, + rgba(245, 230, 237, 0.6) 100%); + backdrop-filter: blur(16rpx) saturate(150%); + border: 2rpx solid rgba(145, 69, 132, 0.3); + border-radius: 48rpx; + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: 0 4rpx 20rpx rgba(145, 69, 132, 0.12); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.city-group-card:active { + transform: scale(0.98); + box-shadow: 0 2rpx 12rpx rgba(145, 69, 132, 0.18); +} + +.group-info { + flex: 1; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 16rpx; + padding-right: 24rpx; +} + +.group-title { + font-size: 40rpx; + font-weight: 700; + color: #1A1A1A; + line-height: 1.4; + white-space: nowrap; +} + +/* 城市选择器 */ +.city-selector { + display: flex; + align-items: center; + gap: 12rpx; + padding: 12rpx 28rpx; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(16rpx) saturate(150%); + border-radius: 100rpx; + border: 2rpx solid rgba(145, 69, 132, 0.2); + box-shadow: 0 4rpx 16rpx rgba(145, 69, 132, 0.08), + 0 2rpx 8rpx rgba(145, 69, 132, 0.04); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; +} + +.city-selector:active { + transform: scale(0.96); + box-shadow: 0 2rpx 12rpx rgba(145, 69, 132, 0.12); + background: rgba(255, 255, 255, 1); +} + +.city-name { + font-size: 32rpx; + font-weight: 700; + color: #914584; + white-space: nowrap; +} + +.city-arrow { + width: 24rpx; + height: 24rpx; + opacity: 0.7; +} + +.join-btn { + padding: 0 40rpx; + height: 88rpx; + background: linear-gradient(135deg, #914584 0%, #7A3A6F 100%); + border-radius: 100rpx; + display: flex; + align-items: center; + justify-content: center; + font-size: 32rpx; + font-weight: 700; + color: #fff; + white-space: nowrap; + flex-shrink: 0; + box-shadow: 0 6rpx 24rpx rgba(145, 69, 132, 0.4), + 0 3rpx 12rpx rgba(145, 69, 132, 0.3); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; +} + +.join-btn:active { + transform: scale(0.96); + box-shadow: 0 4rpx 16rpx rgba(145, 69, 132, 0.45); +} + +/* 活动列表标题 */ +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 32rpx; + margin-bottom: 32rpx; +} + +.section-title { + font-size: 44rpx; + font-weight: 700; + color: #1A1A1A; +} + +.activity-count { + font-size: 28rpx; + color: #914584; + font-weight: 500; +} + +/* 活动列表 - 毛玻璃卡片 */ +.activity-list { + padding: 0 32rpx; +} + +.activity-card { + margin-bottom: 32rpx; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(16rpx); + border-radius: 32rpx; + overflow: hidden; + box-shadow: 0 8rpx 32rpx rgba(145, 69, 132, 0.12), + 0 4rpx 16rpx rgba(145, 69, 132, 0.08); + border: 1rpx solid rgba(145, 69, 132, 0.15); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.activity-card:active { + transform: scale(0.98); + box-shadow: 0 4rpx 16rpx rgba(145, 69, 132, 0.15); +} + +/* 活动图片容器 */ +.activity-image-container { + position: relative; + width: 100%; + height: 360rpx; + overflow: hidden; + background: linear-gradient(135deg, #F5E6ED 0%, #FAF5F8 100%); +} + +.activity-image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.location-badge { + position: absolute; + top: 24rpx; + left: 24rpx; + padding: 12rpx 24rpx; + background: rgba(122, 58, 111, 0.85); + backdrop-filter: blur(12rpx); + border-radius: 100rpx; + display: flex; + align-items: center; + gap: 8rpx; + font-size: 24rpx; + color: #FFFFFF; + font-weight: 500; + box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.15); +} + +.location-icon { + width: 24rpx; + height: 24rpx; +} + +/* 活动信息 */ +.activity-info { + padding: 40rpx; +} + +.activity-title { + font-size: 36rpx; + font-weight: 700; + color: #7A3A6F; + margin-bottom: 20rpx; + line-height: 1.4; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; +} + +.activity-meta { + display: flex; + align-items: center; + gap: 32rpx; + margin-bottom: 24rpx; +} + +.meta-item { + display: flex; + align-items: center; + gap: 8rpx; + font-size: 26rpx; + color: #914584; +} + +.meta-icon { + width: 28rpx; + height: 28rpx; +} + +/* 热度显示样式 */ +.heat-item { + margin-left: auto; +} + +.heat-text { + color: #F97316; + font-weight: 700; +} + +/* 活动底部 */ +.activity-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 24rpx; + border-top: 1rpx solid rgba(145, 69, 132, 0.1); +} + +.heat-info { + display: flex; + align-items: center; + gap: 8rpx; + font-size: 26rpx; + color: #B39DDB; +} + +.heat-icon { + width: 28rpx; + height: 28rpx; +} + +.signup-btn { + width: 220rpx; + height: 72rpx; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #914584 0%, #7A3A6F 100%); + border-radius: 100rpx; + font-size: 28rpx; + font-weight: 700; + color: #FFFFFF; + box-shadow: 0 6rpx 20rpx rgba(145, 69, 132, 0.3); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + white-space: nowrap; + flex-shrink: 0; +} + +.signup-btn:active { + transform: scale(0.95); + box-shadow: 0 4rpx 12rpx rgba(145, 69, 132, 0.35); +} + +/* 空状态 */ +.empty-state { + padding: 120rpx 32rpx; + text-align: center; +} + +.empty-icon { + width: 200rpx; + height: 200rpx; + margin: 0 auto 32rpx; + opacity: 0.5; +} + +.empty-text { + font-size: 28rpx; + color: #B39DDB; +} + +/* 二维码弹窗 */ +.qrcode-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; +} + +/* 遮罩层 */ +.modal-mask { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4rpx); +} + +/* 弹窗内容 */ +.modal-content { + position: relative; + width: 680rpx; + background: #FFFFFF; + border-radius: 64rpx; + padding: 64rpx; + box-shadow: 0 50rpx 100rpx -24rpx rgba(0, 0, 0, 0.25); + display: flex; + flex-direction: column; + align-items: center; + z-index: 1; +} + +/* 关闭按钮 */ +.close-btn { + position: absolute; + top: 32rpx; + right: 32rpx; + width: 72rpx; + height: 72rpx; + background: #F1F5F9; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s ease; +} + +.close-btn:active { + transform: scale(0.9); + background: #E2E8F0; +} + +.close-icon { + width: 40rpx; + height: 40rpx; +} + +/* 标题 */ +.modal-title { + font-size: 48rpx; + font-weight: 700; + color: #1D293D; + text-align: center; + margin-bottom: 16rpx; + line-height: 1.5; +} + +/* 副标题 */ +.modal-subtitle { + font-size: 32rpx; + color: #62748E; + text-align: center; + margin-bottom: 48rpx; + line-height: 1.5; +} + +/* 二维码容器 */ +.qrcode-container { + width: 440rpx; + height: 440rpx; + background: #F8FAFC; + border: 2rpx solid #F1F5F9; + border-radius: 40rpx; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 48rpx; + overflow: hidden; +} + +.qrcode-image { + width: 404rpx; + height: 404rpx; + border-radius: 24rpx; +} + +/* 保存按钮 */ +.save-btn { + width: 552rpx; + height: 116rpx; + background: #07C160; + border-radius: 100rpx; + display: flex; + align-items: center; + justify-content: center; + font-size: 40rpx; + font-weight: 700; + color: #FFFFFF; + box-shadow: 0 20rpx 30rpx -6rpx rgba(220, 252, 231, 1), + 0 8rpx 12rpx -8rpx rgba(220, 252, 231, 1); + transition: all 0.3s ease; +} + +.save-btn:active { + transform: scale(0.96); + box-shadow: 0 10rpx 20rpx -6rpx rgba(220, 252, 231, 1); +} diff --git a/pages/city-selector/city-selector.js b/pages/city-selector/city-selector.js new file mode 100644 index 0000000..ae4ee7b --- /dev/null +++ b/pages/city-selector/city-selector.js @@ -0,0 +1,151 @@ +// pages/city-selector/city-selector.js - 城市选择页面 + +Page({ + data: { + statusBarHeight: 44, + navBarHeight: 44, + totalNavHeight: 88, + + // 当前选中的城市 + selectedCity: '', + + // 热门城市(一线城市) + hotCities: [ + '北京市', '上海市', '广州市', '深圳市', '杭州市', '长沙市' + ], + + // 全国主要城市(按首字母分组) + cityGroups: [ + { + letter: 'A', + cities: ['鞍山市', '安庆市', '安阳市', '安顺市'] + }, + { + letter: 'B', + cities: ['北京市', '保定市', '包头市', '蚌埠市', '宝鸡市', '本溪市', '滨州市'] + }, + { + letter: 'C', + cities: ['重庆市', '成都市', '长沙市', '长春市', '常州市', '沧州市', '承德市', '赤峰市', '潮州市', '郴州市', '滁州市', '常德市'] + }, + { + letter: 'D', + cities: ['大连市', '东莞市', '大庆市', '大同市', '丹东市', '德州市', '东营市', '德阳市', '达州市'] + }, + { + letter: 'F', + cities: ['佛山市', '福州市', '抚顺市', '阜阳市', '抚州市', '防城港市'] + }, + { + letter: 'G', + cities: ['广州市', '贵阳市', '桂林市', '赣州市', '广元市', '贵港市'] + }, + { + letter: 'H', + cities: ['杭州市', '哈尔滨市', '合肥市', '海口市', '呼和浩特市', '惠州市', '邯郸市', '衡阳市', '淮安市', '湖州市', '葫芦岛市', '淮南市', '黄石市', '菏泽市', '衡水市', '淮北市', '黄冈市', '怀化市', '鹤壁市', '河源市', '贺州市'] + }, + { + letter: 'J', + cities: ['济南市', '济宁市', '吉林市', '锦州市', '金华市', '嘉兴市', '江门市', '九江市', '焦作市', '荆州市', '吉安市', '揭阳市', '景德镇市', '晋中市', '晋城市', '荆门市', '鸡西市', '佳木斯市'] + }, + { + letter: 'K', + cities: ['昆明市', '开封市'] + }, + { + letter: 'L', + cities: ['兰州市', '洛阳市', '廊坊市', '临沂市', '柳州市', '连云港市', '聊城市', '泸州市', '漯河市', '娄底市', '六安市', '龙岩市', '莱芜市', '辽阳市', '丽水市', '六盘水市', '辽源市', '来宾市', '临汾市', '吕梁市'] + }, + { + letter: 'M', + cities: ['绵阳市', '茂名市', '梅州市', '马鞍山市', '牡丹江市', '眉山市'] + }, + { + letter: 'N', + cities: ['南京市', '宁波市', '南昌市', '南宁市', '南通市', '南阳市', '南充市', '宁德市', '内江市', '南平市'] + }, + { + letter: 'P', + cities: ['平顶山市', '盘锦市', '莆田市', '萍乡市', '濮阳市', '攀枝花市'] + }, + { + letter: 'Q', + cities: ['青岛市', '泉州市', '秦皇岛市', '齐齐哈尔市', '清远市', '曲靖市', '衢州市', '钦州市', '庆阳市'] + }, + { + letter: 'R', + cities: ['日照市'] + }, + { + letter: 'S', + cities: ['上海市', '深圳市', '苏州市', '沈阳市', '石家庄市', '汕头市', '绍兴市', '三亚市', '宿迁市', '商丘市', '十堰市', '韶关市', '遂宁市', '宿州市', '邵阳市', '上饶市', '汕尾市', '三明市', '朔州市', '四平市', '松原市', '随州市', '绥化市', '双鸭山市', '石嘴山市'] + }, + { + letter: 'T', + cities: ['天津市', '太原市', '唐山市', '泰安市', '台州市', '泰州市', '铁岭市', '通辽市', '通化市', '铜陵市', '铜川市', '铜仁市', '天水市'] + }, + { + letter: 'W', + cities: ['武汉市', '无锡市', '温州市', '潍坊市', '芜湖市', '威海市', '乌鲁木齐市', '梧州市', '渭南市', '乌海市', '乌兰察布市'] + }, + { + letter: 'X', + cities: ['西安市', '厦门市', '徐州市', '襄阳市', '新乡市', '湘潭市', '许昌市', '信阳市', '咸阳市', '孝感市', '邢台市', '咸宁市', '宣城市', '忻州市', '西宁市', '湘西土家族苗族自治州'] + }, + { + letter: 'Y', + cities: ['烟台市', '扬州市', '宜昌市', '盐城市', '银川市', '岳阳市', '运城市', '榆林市', '宜宾市', '阳江市', '玉林市', '宜春市', '营口市', '益阳市', '永州市', '玉溪市', '延安市', '鹰潭市', '伊春市', '云浮市', '阳泉市', '延边朝鲜族自治州'] + }, + { + letter: 'Z', + cities: ['郑州市', '珠海市', '中山市', '淄博市', '株洲市', '镇江市', '湛江市', '漳州市', '遵义市', '舟山市', '枣庄市', '张家口市', '周口市', '驻马店市', '肇庆市', '自贡市', '资阳市', '张家界市', '昭通市', '中卫市', '张掖市'] + } + ] + }, + + onLoad(options) { + // 获取导航栏高度 + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + const menuButton = wx.getMenuButtonBoundingClientRect() + const navBarHeight = menuButton.height + (menuButton.top - statusBarHeight) * 2 + const totalNavHeight = statusBarHeight + navBarHeight + + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight, + selectedCity: options.current || '深圳市' + }) + }, + + /** + * 返回上一页 + */ + onBack() { + wx.navigateBack() + }, + + /** + * 选择城市 + */ + onSelectCity(e) { + const city = e.currentTarget.dataset.city + + // 获取上一页面 + const pages = getCurrentPages() + const prevPage = pages[pages.length - 2] + + // 更新上一页面的城市数据 + if (prevPage) { + prevPage.setData({ selectedCity: city }) + // 如果上一页面有loadActivityList方法,调用它重新加载数据 + if (typeof prevPage.loadActivityList === 'function') { + prevPage.loadActivityList() + } + } + + // 返回上一页 + wx.navigateBack() + } +}) diff --git a/pages/city-selector/city-selector.json b/pages/city-selector/city-selector.json new file mode 100644 index 0000000..b16ef3e --- /dev/null +++ b/pages/city-selector/city-selector.json @@ -0,0 +1,4 @@ +{ + "navigationStyle": "custom", + "usingComponents": {} +} diff --git a/pages/city-selector/city-selector.wxml b/pages/city-selector/city-selector.wxml new file mode 100644 index 0000000..d8b4a1e --- /dev/null +++ b/pages/city-selector/city-selector.wxml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + 选择城市 + + + + + + + + 热门城市 + + + {{item}} + + + + + + + 全部城市 + + + {{item.letter}} + + + {{city}} + + + + + + + diff --git a/pages/city-selector/city-selector.wxss b/pages/city-selector/city-selector.wxss new file mode 100644 index 0000000..dedf000 --- /dev/null +++ b/pages/city-selector/city-selector.wxss @@ -0,0 +1,137 @@ +/* pages/city-selector/city-selector.wxss */ + +.page { + min-height: 100vh; + background: linear-gradient(180deg, #F8F5FF 0%, #FFF5F8 100%); +} + +/* ========== 固定导航栏 ========== */ +.nav-container { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 999; + background: rgba(242, 237, 255, 0.6); + backdrop-filter: blur(10px); +} + +.status-bar { + background: transparent; +} + +.nav-bar { + display: flex; + align-items: center; + justify-content: center; + padding: 0 32rpx; + background: transparent; + position: relative; +} + +.nav-back { + position: absolute; + left: 32rpx; + top: 50%; + transform: translateY(-50%); + width: 80rpx; + height: 80rpx; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +.back-icon { + width: 48rpx; + height: 48rpx; +} + +.nav-title { + font-size: 36rpx; + font-weight: 700; + color: #1F2937; + line-height: 1; +} + +/* ========== 内容滚动区域 ========== */ +.content-scroll { + height: 100vh; + box-sizing: border-box; +} + +/* ========== 分区 ========== */ +.section { + padding: 32rpx; +} + +.section-title { + font-size: 32rpx; + font-weight: 700; + color: #1F2937; + margin-bottom: 24rpx; +} + +/* ========== 热门城市 ========== */ +.hot-cities { + display: flex; + flex-wrap: wrap; + gap: 24rpx; +} + +.city-tag { + padding: 20rpx 40rpx; + background: rgba(255, 255, 255, 0.8); + border-radius: 16rpx; + font-size: 28rpx; + color: #4B5563; + border: 2rpx solid transparent; + transition: all 0.2s; +} + +.city-tag.active { + background: linear-gradient(135deg, #A78BFA 0%, #EC4899 100%); + color: #FFFFFF; + font-weight: 600; + border-color: transparent; +} + +/* ========== 城市分组 ========== */ +.city-groups { + margin-top: 16rpx; +} + +.city-group { + margin-bottom: 32rpx; +} + +.group-letter { + font-size: 28rpx; + font-weight: 700; + color: #A78BFA; + margin-bottom: 16rpx; + padding-left: 8rpx; +} + +.group-cities { + display: flex; + flex-wrap: wrap; + gap: 16rpx; +} + +.city-item { + padding: 16rpx 32rpx; + background: rgba(255, 255, 255, 0.6); + border-radius: 12rpx; + font-size: 26rpx; + color: #4B5563; + border: 2rpx solid transparent; + transition: all 0.2s; +} + +.city-item.active { + background: linear-gradient(135deg, #A78BFA 0%, #EC4899 100%); + color: #FFFFFF; + font-weight: 600; + border-color: transparent; +} diff --git a/pages/commission/commission.js b/pages/commission/commission.js new file mode 100644 index 0000000..d13571f --- /dev/null +++ b/pages/commission/commission.js @@ -0,0 +1,666 @@ +// pages/commission/commission.js - 佣金明细页面 +// 对接后端API + +const api = require('../../utils/api') +const errorHandler = require('../../utils/errorHandler') + +// 缓存配置 +const CACHE_CONFIG = { + STATS_KEY: 'commission_stats_cache', + RECORDS_KEY: 'commission_records_cache', + EXPIRE_TIME: 5 * 60 * 1000 // 5分钟有效期 +} + +Page({ + data: { + statusBarHeight: 44, + navBarHeight: 44, + totalNavHeight: 88, + navHeight: 96, // Keep for compatibility if used elsewhere + loading: false, + currentTab: 'all', + // 佣金统计 + isDistributor: false, + referralCode: '', + cardType: '', + commissionBalance: '0.00', + totalCommission: '0.00', + pendingAmount: '0.00', // Confirmed field for display + totalWithdrawn: '0.00', + totalReferrals: 0, + totalContribution: '0.00', + // 佣金比例 + rechargeRate: 10, + vipRate: 15, + cardRate: 20, + // 兼容旧字段 + totalEarnings: '0.00', + availableBalance: '0.00', + withdrawnAmount: '0.00', + // 记录列表 + allList: [], + commissionList: [], + page: 1, + hasMore: true, + // 缓存状态 + cacheExpired: false, + lastUpdateTime: '', + defaultAvatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=500&auto=format&fit=crop&q=60', + cardTitle: '守护会员', + pickerDate: '', // YYYY-MM + pickerDateDisplay: '' // YYYY年MM月 + }, + + onLoad() { + // Init Date + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + + this.setData({ + pickerDate: `${year}-${month}`, + pickerDateDisplay: `${year}年${month}月` + }); + + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + const navHeight = statusBarHeight + 48 + + this.setData({ + statusBarHeight, + navHeight + }) + + // 先尝试加载缓存(快速显示) + this.loadFromCache() + + // 然后加载最新数据(确保数据准确) + this.loadCommissionStats() + this.loadCommissionRecords() + }, + + /** + * 页面显示时刷新数据 + */ + onShow() { + // 每次显示页面时,强制刷新用户信息 + // 确保从其他页面返回时数据是最新的 + this.loadCommissionStats() + }, + + /** + * 从缓存加载数据 + */ + loadFromCache() { + try { + // 加载统计数据缓存 + const statsCache = wx.getStorageSync(CACHE_CONFIG.STATS_KEY) + if (statsCache && this.isCacheValid(statsCache.timestamp)) { + const cachedData = statsCache.data || {} + // 强制修正缓存中的错误规定值 + if (cachedData.pendingAmount === '210.00' || cachedData.pendingAmount === 210) { + cachedData.pendingAmount = '0.00' + } + this.setData({ + ...cachedData, + cacheExpired: false, + lastUpdateTime: this.formatCacheTime(statsCache.timestamp) + }) + } else { + this.setData({ cacheExpired: true }) + } + + // 加载记录列表缓存 + const recordsCache = wx.getStorageSync(CACHE_CONFIG.RECORDS_KEY) + if (recordsCache && this.isCacheValid(recordsCache.timestamp)) { + this.setData({ + allList: recordsCache.data, + commissionList: recordsCache.data + }) + } + } catch (error) { + console.error('加载缓存失败:', error) + } + }, + + /** + * 检查缓存是否有效 + */ + isCacheValid(timestamp) { + if (!timestamp) return false + const now = Date.now() + return (now - timestamp) < CACHE_CONFIG.EXPIRE_TIME + }, + + /** + * 格式化缓存时间 + */ + formatCacheTime(timestamp) { + const date = new Date(timestamp) + const hour = String(date.getHours()).padStart(2, '0') + const minute = String(date.getMinutes()).padStart(2, '0') + return `${hour}:${minute}` + }, + + /** + * 保存统计数据到缓存 + */ + saveStatsToCache(data) { + try { + wx.setStorageSync(CACHE_CONFIG.STATS_KEY, { + data, + timestamp: Date.now() + }) + } catch (error) { + console.error('保存统计缓存失败:', error) + } + }, + + /** + * 保存记录列表到缓存 + */ + saveRecordsToCache(records) { + try { + wx.setStorageSync(CACHE_CONFIG.RECORDS_KEY, { + data: records, + timestamp: Date.now() + }) + } catch (error) { + console.error('保存记录缓存失败:', error) + } + }, + + /** + * 清除缓存 + */ + clearCache() { + try { + wx.removeStorageSync(CACHE_CONFIG.STATS_KEY) + wx.removeStorageSync(CACHE_CONFIG.RECORDS_KEY) + this.setData({ cacheExpired: true, lastUpdateTime: '' }) + wx.showToast({ + title: '缓存已清除', + icon: 'success' + }) + } catch (error) { + console.error('清除缓存失败:', error) + } + }, + + /** + * 下拉刷新 + */ + onPullDownRefresh() { + // 清除缓存,强制刷新 + this.clearCache() + + Promise.all([ + this.loadCommissionStats(), + this.loadCommissionRecords() + ]).then(() => { + wx.stopPullDownRefresh() + wx.showToast({ + title: '刷新成功', + icon: 'success' + }) + }) + }, + + /** + * 上拉加载更多 + */ + onReachBottom() { + if (this.data.hasMore && !this.data.loading) { + this.loadMoreRecords() + } + }, + + /** + * 加载佣金统计 + */ + async loadCommissionStats() { + try { + const res = await api.commission.getStats() + + if (res.success && res.data) { + const data = res.data + const statsData = { + isDistributor: data.isDistributor || false, + referralCode: data.referralCode || '', + cardType: data.cardType || '', + commissionBalance: Number(data.commissionBalance || 0).toFixed(2), + totalCommission: Number(data.totalCommission || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 }), + pendingAmount: Number(data.pendingWithdrawal || data.pendingAmount || 0).toFixed(2), + totalWithdrawn: Number(data.totalWithdrawn || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 }), + totalReferrals: data.totalReferrals || 0, + totalContribution: Number(data.totalContribution || data.total_contribution || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 }), + pendingWithdrawal: Number(data.pendingWithdrawal || 0).toFixed(2), + rechargeRate: data.rechargeRate || 10, + vipRate: data.vipRate || 15, + cardRate: data.cardRate || 20, + // 兼容旧字段 + totalEarnings: Number(data.totalCommission || data.total_earnings || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 }), + availableBalance: Number(data.commissionBalance || data.available_balance || 0).toFixed(2), + withdrawnAmount: Number(data.totalWithdrawn || data.withdrawn_amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 }) + } + + this.setData({ + ...statsData, + cardTitle: this.getCardTitle(statsData.cardType), + cacheExpired: false, + lastUpdateTime: this.formatCacheTime(Date.now()) + }) + + // 保存到缓存 + this.saveStatsToCache(statsData) + + // 如果不是分销商,显示引导提示 + if (!data.isDistributor) { + this.showBecomeDistributorTip() + } + } + } catch (err) { + console.log('获取佣金统计失败', err) + // 如果有缓存,显示缓存失效提示 + if (!this.data.cacheExpired) { + wx.showToast({ + title: '数据加载失败,显示缓存数据', + icon: 'none', + duration: 2000 + }) + } + } + }, + + /** + * 显示成为分销商提示 + */ + showBecomeDistributorTip() { + wx.showModal({ + title: '成为分销商', + content: '购买身份卡即可成为分销商,推荐好友赚佣金!', + confirmText: '了解详情', + cancelText: '暂不需要', + success: (res) => { + if (res.confirm) { + // 跳转到推广页面查看详情 + wx.navigateTo({ + url: '/pages/promote/promote' + }) + } + } + }) + }, + + /** + * 加载佣金记录 + */ + async loadCommissionRecords() { + this.setData({ loading: true, page: 1 }) + + try { + const res = await api.commission.getRecords({ page: 1, limit: 20 }) + + if (res.success && res.data) { + const records = (res.data.list || res.data || []).map(record => this.transformRecord(record)) + + this.setData({ + allList: records, + commissionList: records, + hasMore: records.length >= 20, + loading: false + }) + + // 保存到缓存 + this.saveRecordsToCache(records) + } else { + this.setData({ allList: [], commissionList: [], loading: false }) + } + } catch (err) { + console.error('加载佣金记录失败', err) + this.setData({ loading: false }) + + // 如果有缓存,显示缓存失效提示 + if (this.data.allList.length > 0) { + wx.showToast({ + title: '数据加载失败,显示缓存数据', + icon: 'none', + duration: 2000 + }) + } + } + }, + + /** + * 加载更多记录 + */ + async loadMoreRecords() { + const nextPage = this.data.page + 1 + this.setData({ loading: true }) + + try { + const res = await api.commission.getRecords({ page: nextPage, limit: 20 }) + + if (res.success && res.data) { + const newRecords = (res.data.list || res.data || []).map(record => this.transformRecord(record)) + const allList = [...this.data.allList, ...newRecords] + + this.setData({ + allList, + page: nextPage, + hasMore: newRecords.length >= 20, + loading: false + }) + + // 更新当前筛选列表 + this.filterRecords(this.data.currentTab) + } else { + this.setData({ hasMore: false, loading: false }) + } + } catch (err) { + console.error('加载更多记录失败', err) + this.setData({ loading: false }) + } + }, + + /** + * 转换记录数据格式 + */ + transformRecord(record) { + let titleText = record.fromUserName || record.userName || '用户'; + let descText = 'VIP月卡'; + if (record.amount > 100) descText = 'SVIP年卡'; + + if (record.fromUserLevel) { + descText = this.getUserLevelText(record.fromUserLevel); + } else if (record.orderType === 'vip' || record.orderType === 'svip') { + descText = record.orderType.toUpperCase() === 'SVIP' ? 'SVIP会员' : 'VIP会员'; + } else if (record.orderType === 'identity_card') { + descText = '身份会员'; + } else if (record.orderType === 'companion_chat') { + descText = '陪伴聊天'; + } + + const dateObj = new Date(record.created_at || record.createdAt); + const mm = String(dateObj.getMonth() + 1).padStart(2, '0'); + const dd = String(dateObj.getDate()).padStart(2, '0'); + const hh = String(dateObj.getHours()).padStart(2, '0'); + const min = String(dateObj.getMinutes()).padStart(2, '0'); + const fmtTime = `${mm}-${dd} ${hh}:${min}`; + + return { + id: record.id, + type: record.type, + title: titleText, + desc: descText, + amount: record.commissionAmount ? record.commissionAmount.toFixed(0) : (record.amount ? Number(record.amount).toFixed(0) : '0'), + status: record.status || 'settled', + statusText: record.status === 'pending' ? '待结算' : '已结算', + time: this.formatTime(record.created_at || record.createdAt), + orderNo: record.orderId ? `ORD${record.orderId.substring(0, 12)}` : 'ORD2024012401', + userAvatar: record.userAvatar || record.fromUserAvatar || record.avatar || '', + listTitle: titleText, + fmtTime: fmtTime, + } + }, + + getUserLevelText(level) { + const levelMap = { + 'vip': 'VIP会员', + 'svip': 'SVIP会员', + 'guardian': '守护会员', + 'companion': '陪伴会员', + 'partner': '城市合伙人', + '1': '普通用户', + '2': 'VIP会员', + '3': 'SVIP会员', + '4': '守护会员', + '5': '陪伴会员', + '6': '城市合伙人' + }; + return levelMap[level] || levelMap['1']; + }, + + getCardTitle(type) { + const map = { + 'guardian_card': '守护会员', + 'companion_card': '陪伴会员', + 'identity_card': '身份会员', + 'vip': 'VIP会员', + 'partner': '城市合伙人' + }; + return map[type] || '守护会员'; + }, + + goToTeam() { + wx.navigateTo({ + url: '/pages/team/team', + }); + }, + + /** + * 格式化时间 + */ + formatTime(dateStr) { + if (!dateStr) return '' + const date = new Date(dateStr) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const hour = String(date.getHours()).padStart(2, '0') + const minute = String(date.getMinutes()).padStart(2, '0') + return `${year}-${month}-${day} ${hour}:${minute}` + }, + + onBack() { + wx.navigateBack() + }, + + onTabChange(e) { + const tab = e.currentTarget.dataset.tab + this.setData({ currentTab: tab }) + this.filterRecords(tab) + }, + + /** + * 筛选记录 + */ + filterRecords(tab) { + let list = [] + + if (tab === 'all') { + list = this.data.allList + } else if (tab === 'settled') { + list = this.data.allList.filter(item => item.status === 'settled') + } else if (tab === 'pending') { + list = this.data.allList.filter(item => item.status === 'pending') + } + + this.setData({ commissionList: list }) + }, + + onDateChange(e) { + const val = e.detail.value; // YYYY-MM + const [y, m] = val.split('-'); + this.setData({ + pickerDate: val, + pickerDateDisplay: `${y}年${m}月` + }); + // TODO: Call API to reload records with date filter if needed + this.loadCommissionRecords(); // Reload with new date + }, + + /** + * 跳转到提现页面 + */ + onWithdraw() { + wx.navigateTo({ + url: '/pages/withdraw/withdraw' + }) + }, + + /** + * 跳转到推广页面 + */ + onPromote() { + wx.navigateTo({ + url: '/pages/promote/promote' + }) + }, + + /** + * 跳转到推荐用户列表 + */ + async goToReferrals() { + // 显示加载提示 + wx.showLoading({ title: '加载中...', mask: true }) + + try { + // 强制刷新用户信息,确保使用最新数据 + const res = await api.commission.getStats() + + if (res.success && res.data) { + const isDistributor = res.data.isDistributor || false + + // 更新本地状态 + this.setData({ isDistributor }) + + wx.hideLoading() + + // 检查是否是分销商 + if (!isDistributor) { + wx.showModal({ + title: '成为分销商', + content: '购买身份卡即可成为分销商,推荐好友赚佣金!', + confirmText: '了解详情', + cancelText: '取消', + success: (modalRes) => { + if (modalRes.confirm) { + wx.navigateTo({ + url: '/pages/promote/promote' + }) + } + } + }) + return + } + + // 跳转到推荐用户列表 + wx.navigateTo({ + url: '/pages/referrals/referrals' + }) + } else { + wx.hideLoading() + wx.showToast({ + title: '获取用户信息失败', + icon: 'none' + }) + } + } catch (error) { + wx.hideLoading() + console.error('获取用户信息失败:', error) + wx.showToast({ + title: '网络错误,请重试', + icon: 'none' + }) + } + }, + + /** + * 跳转到提现记录 + */ + goToWithdrawRecords() { + wx.navigateTo({ + url: '/pages/withdraw-records/withdraw-records' + }) + }, + + /** + * 微信分享推荐码 + */ + onShareAppMessage() { + const { referralCode, isDistributor } = this.data + + if (!isDistributor || !referralCode) { + return { + title: '心伴AI - 情感陪伴聊天机器人', + path: '/pages/index/index', + imageUrl: '/images/share-cover.jpg' + } + } + + return { + title: `我的推荐码:${referralCode},注册即可享受优惠!`, + path: `/pages/index/index?referralCode=${referralCode}`, + imageUrl: '/images/share-commission.png' + } + }, + + /** + * 分享到朋友圈 + */ + onShareTimeline() { + const { referralCode, isDistributor } = this.data + + if (!isDistributor || !referralCode) { + return { + title: '心伴AI - 情感陪伴聊天机器人', + imageUrl: '/images/share-cover.jpg' + } + } + + return { + title: `我的推荐码:${referralCode},注册即可享受优惠!`, + query: `referralCode=${referralCode}`, + imageUrl: '/images/share-commission.png' + } + }, + + /** + * 复制推荐码 + */ + copyReferralCode() { + const { referralCode } = this.data + if (!referralCode) { + wx.showToast({ title: '暂无推荐码', icon: 'none' }) + return + } + + wx.setClipboardData({ + data: referralCode, + success: () => { + wx.showToast({ title: '已复制推荐码', icon: 'success' }) + } + }) + }, + + /** + * 绑定推荐码 + */ + bindReferralCode() { + wx.showModal({ + title: '绑定推荐码', + editable: true, + placeholderText: '请输入推荐码', + success: async (res) => { + if (res.confirm && res.content) { + try { + wx.showLoading({ title: '绑定中...' }) + const result = await api.commission.bindReferral(res.content.trim()) + wx.hideLoading() + + if (result.success) { + wx.showToast({ title: '绑定成功', icon: 'success' }) + this.loadCommissionStats() + } else { + wx.showToast({ title: result.message || '绑定失败', icon: 'none' }) + } + } catch (err) { + wx.hideLoading() + wx.showToast({ title: err.message || '绑定失败', icon: 'none' }) + } + } + } + }) + } +}) diff --git a/pages/commission/commission.json b/pages/commission/commission.json new file mode 100644 index 0000000..d6cb08e --- /dev/null +++ b/pages/commission/commission.json @@ -0,0 +1,6 @@ +{ + "usingComponents": { + "app-icon": "/components/icon/icon" + }, + "navigationStyle": "custom" +} diff --git a/pages/commission/commission.wxml b/pages/commission/commission.wxml new file mode 100644 index 0000000..f73447a --- /dev/null +++ b/pages/commission/commission.wxml @@ -0,0 +1,88 @@ + + + + + + + + 佣金明细 + + + + + + + + + 我的账户 + + + 可提现金额 (元) + {{commissionBalance}} + + + + + + + 待结算 ¥ {{pendingAmount}} + + + + + + 累计结算 ¥ {{totalCommission}} + + + + + + + + 佣金记录 + + + + + + 全部 + + + 已结算 + + + 待结算 + + + + + + 加载中... + 暂无记录 + + + + + + + + {{item.title}} + {{item.desc}} + + + + ¥{{item.amount}} + + {{item.statusText}} + + + + + {{item.time}} + {{item.orderNo}} + + + + 已显示全部数据 + + diff --git a/pages/commission/commission.wxss b/pages/commission/commission.wxss new file mode 100644 index 0000000..f585019 --- /dev/null +++ b/pages/commission/commission.wxss @@ -0,0 +1,266 @@ +.page { + min-height: 100vh; + background: #F8F9FC; + display: flex; + flex-direction: column; +} + +.nav-container { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 999; +} + +.status-bar { background: transparent; } + +.nav-bar { + display: flex; + align-items: center; + justify-content: center; + padding: 0 32rpx; + position: relative; +} + +.nav-back { + position: absolute; + left: 32rpx; + top: 50%; + transform: translateY(-50%); + width: 80rpx; + height: 80rpx; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +.back-icon { + width: 48rpx; + height: 48rpx; +} + +.nav-title { + font-size: 34rpx; + font-weight: 700; + color: #1A1A1A; +} + +/* Top Card Section */ +.top-section { + padding: 20rpx 32rpx; + background: #F8F9FC; +} + +.commission-card { + background: linear-gradient(135deg, #CF91D3 0%, #B06AB3 100%); + border-radius: 40rpx; + padding: 40rpx 48rpx; + color: #FFFFFF; + box-shadow: 0 10rpx 30rpx rgba(176, 106, 179, 0.3); +} + +.card-header { + display: flex; + align-items: center; + gap: 16rpx; + margin-bottom: 32rpx; +} + +.card-title { + font-size: 32rpx; + font-weight: 600; +} + +.balance-label { + display: block; + font-size: 26rpx; + opacity: 0.9; + margin-bottom: 8rpx; +} + +.balance-value { + font-size: 80rpx; + font-weight: 800; + line-height: 1; + margin-bottom: 40rpx; + display: block; +} + +.divider { + height: 1rpx; + background: rgba(255,255,255,0.2); + margin-bottom: 24rpx; +} + +.stats-row { + display: flex; + align-items: center; +} + +.stat-item { + display: flex; + align-items: center; + gap: 12rpx; +} + +.stat-text { + font-size: 28rpx; + font-weight: 500; +} + +/* List Section */ +.record-title-row { + padding: 20rpx 32rpx 10rpx; +} + +.record-title { + font-size: 34rpx; + font-weight: 800; + color: #111827; +} + +/* Tabs */ +.tabs-container { + display: flex; + align-items: center; + gap: 20rpx; + padding: 20rpx 32rpx; +} + +.tab-item { + padding: 16rpx 48rpx; + border-radius: 40rpx; + font-size: 28rpx; + color: #6B7280; + font-weight: 500; +} + +.tab-item.active { + background: #B06AB3; + color: #FFFFFF; + font-weight: 600; + box-shadow: 0 4rpx 12rpx rgba(176, 106, 179, 0.3); +} + +.tab-item.active-text { + color: #111827; + font-weight: 700; +} + +/* Record List */ +.record-list { + padding: 10rpx 32rpx; +} + +.record-card { + background: #FFFFFF; + border-radius: 32rpx; + padding: 32rpx; + margin-bottom: 24rpx; + box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.02); +} + +.card-top { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24rpx; +} + +.left-info { + display: flex; + align-items: center; +} + +.user-avatar { + width: 80rpx; + height: 80rpx; + border-radius: 50%; + margin-right: 20rpx; +} + +.text-info { + display: flex; + flex-direction: column; + gap: 4rpx; + max-width: 280rpx; +} + +.user-name { + font-size: 30rpx; + font-weight: 700; + color: #111827; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-role { + font-size: 24rpx; + color: #9CA3AF; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.right-info { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8rpx; +} + +.amount-val { + font-size: 36rpx; + font-weight: 800; + color: #B06AB3; +} + +.status-badge { + padding: 4rpx 12rpx; + border-radius: 8rpx; + font-size: 22rpx; +} + +.status-badge.green { + background: #DCFCE7; + color: #16A34A; +} + +.status-badge.gray { + background: #F3F4F6; + color: #9CA3AF; +} + +.card-bottom { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 24rpx; + border-top: 1rpx solid #F9FAFB; +} + +.record-time { + font-size: 24rpx; + color: #9CA3AF; +} + +.record-id { + font-size: 22rpx; + color: #D1D5DB; +} + +.footer-tip { + text-align: center; + color: #D1D5DB; + font-size: 24rpx; + padding: 40rpx 0; +} + +.loading, .empty { + text-align: center; + padding: 60rpx; + color: #9CA3AF; +} diff --git a/pages/companion-apply/ai.code-workspace b/pages/companion-apply/ai.code-workspace new file mode 100644 index 0000000..8e9ff67 --- /dev/null +++ b/pages/companion-apply/ai.code-workspace @@ -0,0 +1,11 @@ +{ + "folders": [ + { + "path": "../../../.." + }, + { + "path": "../../../../../ai-c.maimanji.com_fFGTY" + } + ], + "settings": {} +} \ No newline at end of file diff --git a/pages/companion-apply/companion-apply.js b/pages/companion-apply/companion-apply.js new file mode 100644 index 0000000..c3aa3b8 --- /dev/null +++ b/pages/companion-apply/companion-apply.js @@ -0,0 +1,220 @@ +// pages/companion-apply/companion-apply.js +// 陪聊师申请页面 - 根据 Figma 设计重构 +const api = require('../../utils/api') + +Page({ + data: { + statusBarHeight: 44, + navBarHeight: 44, + totalNavHeight: 88, + applyStatus: 'none', // none, pending, approved, rejected + statusTitle: '', + statusDesc: '', + showForm: true, + agreed: false, + formData: { + realName: '', + city: '', + phone: '', + remarks: '' + }, + canSubmit: false + }, + + onLoad() { + // 获取系统信息设置导航栏高度 + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + const menuButton = wx.getMenuButtonBoundingClientRect() + const navBarHeight = menuButton.height + (menuButton.top - statusBarHeight) * 2 + const totalNavHeight = statusBarHeight + navBarHeight + + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight + }) + + this.checkApplyStatus() + }, + + // 返回上一页 + goBack() { + wx.navigateBack() + }, + + // 检查申请状态 + async checkApplyStatus() { + // 先检查是否已登录(通过 Token 判断) + const token = wx.getStorageSync('auth_token') + console.log('checkApplyStatus - token:', token ? '已登录' : '未登录') + + if (!token) { + console.log('用户未登录,显示申请表单') + this.setData({ applyStatus: 'none' }) + return + } + + wx.showLoading({ title: '加载中...' }) + try { + const res = await api.companion.getApplyStatus() + console.log('获取申请状态响应:', res) + + // 兼容两种响应格式: { success: true, data } 或 { code: 0, data } + const isSuccess = res.success || res.code === 0 + + if (isSuccess && res.data) { + const data = res.data + + // 使用新的状态标识字段 + if (data.isApproved) { + this.setData({ + applyStatus: 'approved', + statusTitle: '您的资料已审核通过', + statusDesc: '恭喜您成为陪聊师!现在可以进入工作台开始接单了' + }) + } else if (data.isPending) { + this.setData({ + applyStatus: 'pending', + statusTitle: '正在审核中', + statusDesc: '您已提交陪聊师申请,正在审核中,请耐心等待' + }) + } else if (data.isRejected) { + this.setData({ + applyStatus: 'rejected', + statusTitle: '申请未通过', + statusDesc: data.rejectReason || '很抱歉,您的申请未通过审核,您可以修改信息后重新申请' + }) + } else if (data.canApply) { + // 可以申请(没有申请记录或被拒绝后可重新申请) + this.setData({ applyStatus: 'none' }) + } else { + // 兼容旧格式,使用 status 字段 + const status = data.status + if (status) { + this.setData({ + applyStatus: status, + statusTitle: this.getStatusTitle(status), + statusDesc: this.getStatusDesc(status, data.rejectReason || data.reject_reason) + }) + } else { + this.setData({ applyStatus: 'none' }) + } + } + } else { + console.log('没有申请记录,显示申请表单') + this.setData({ applyStatus: 'none' }) + } + } catch (err) { + console.error('获取申请状态失败:', err) + this.setData({ applyStatus: 'none' }) + } finally { + wx.hideLoading() + } + }, + + // 获取状态标题 + getStatusTitle(status) { + const titles = { + 'pending': '正在审核中', + 'reviewing': '正在审核中', + 'approved': '您的资料已审核通过', + 'rejected': '申请未通过' + } + return titles[status] || '正在审核中' + }, + + // 获取状态描述 + getStatusDesc(status, rejectReason) { + const descs = { + 'pending': '您已提交陪聊师申请,正在审核中,请耐心等待', + 'reviewing': '您已提交陪聊师申请,正在审核中,请耐心等待', + 'approved': '恭喜您成为陪聊师!现在可以进入工作台开始接单了', + 'rejected': rejectReason || '很抱歉,您的申请未通过审核,您可以修改信息后重新申请' + } + return descs[status] || '您的申请正在处理中' + }, + + // 重新申请 + reapply() { + this.setData({ showForm: true, applyStatus: 'none' }) + }, + + // 输入变化 + onInputChange(e) { + const field = e.currentTarget.dataset.field + const value = e.detail.value + this.setData({ + [`formData.${field}`]: value + }) + this.checkCanSubmit() + }, + + // 切换协议同意状态 + toggleAgreement() { + this.setData({ + agreed: !this.data.agreed + }) + this.checkCanSubmit() + }, + + // 查看协议 + viewAgreement() { + wx.navigateTo({ + url: '/pages/agreement/agreement?code=cooperation_service' + }) + }, + + // 检查是否可以提交 + checkCanSubmit() { + const { formData, agreed } = this.data + + const canSubmit = + formData.realName && + formData.phone && + formData.phone.length === 11 && + agreed + + this.setData({ canSubmit }) + }, + + // 提交申请 + async submitApply() { + if (!this.data.canSubmit) return + + const { formData } = this.data + + // 验证手机号 + if (!/^1[3-9]\d{9}$/.test(formData.phone)) { + wx.showToast({ title: '请输入正确的手机号', icon: 'none' }) + return + } + + wx.showLoading({ title: '提交中...' }) + try { + const res = await api.companion.apply({ + realName: formData.realName, + city: formData.city, + phone: formData.phone, + remarks: formData.remarks + }) + + if (res.success || res.code === 0) { + wx.showToast({ title: '申请已提交', icon: 'success' }) + this.setData({ + applyStatus: 'pending', + statusTitle: '申请审核中', + statusDesc: '您的申请正在审核中,请耐心等待,我们会尽快处理', + showForm: false + }) + } else { + wx.showToast({ title: res.message || '提交失败', icon: 'none' }) + } + } catch (err) { + console.error('提交申请失败:', err) + wx.showToast({ title: err.message || '提交失败', icon: 'none' }) + } finally { + wx.hideLoading() + } + } +}) diff --git a/pages/companion-apply/companion-apply.json b/pages/companion-apply/companion-apply.json new file mode 100644 index 0000000..8e57e35 --- /dev/null +++ b/pages/companion-apply/companion-apply.json @@ -0,0 +1,5 @@ +{ + "usingComponents": {}, + "navigationBarTitleText": "申请成为陪聊师", + "navigationStyle": "custom" +} diff --git a/pages/companion-apply/companion-apply.wxml b/pages/companion-apply/companion-apply.wxml new file mode 100644 index 0000000..2eb0a2b --- /dev/null +++ b/pages/companion-apply/companion-apply.wxml @@ -0,0 +1,115 @@ + + + + + + + + 返回 + + 申请成为陪聊师 + + + + + + + + + + + + + + + + {{statusTitle}} + {{statusDesc}} + + + + + + + + + + + 基本信息 + + + + + 姓名 + * + + + + + + + + + 所在城市 + (选填) + + + + + + + + + 手机号 + * + + + + + + + + + + + 备注信息 + + + + + + {{formData.remarks.length || 0}}/500 + + + + + + + + + + 我已阅读并同意 + 《倾听陪聊师服务协议》 + + + + + + + + + + + + + + 我是陪聊师,进入工作台 + + + diff --git a/pages/companion-apply/companion-apply.wxss b/pages/companion-apply/companion-apply.wxss new file mode 100644 index 0000000..0aa76c0 --- /dev/null +++ b/pages/companion-apply/companion-apply.wxss @@ -0,0 +1,453 @@ +/* 陪聊师申请页面样式 - 根据 Figma 设计重构 */ +.page-container { + min-height: 100vh; + background: #FCE7F3; +} + +/* 顶部导航栏 */ +.nav-header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + background: #FCE7F3; + box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.1); +} + +.nav-content { + height: 96rpx; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 32rpx; +} + +.nav-back { + display: flex; + align-items: center; + gap: 4rpx; + padding: 16rpx; + margin-left: -16rpx; +} + +.back-icon { + width: 56rpx; + height: 56rpx; +} + +.back-text { + font-size: 34rpx; + font-weight: 700; + color: #101828; +} + +.nav-title { + font-size: 40rpx; + font-weight: 700; + color: #101828; +} + +.nav-right { + width: 160rpx; + display: flex; + justify-content: flex-end; + align-items: center; + gap: 24rpx; +} + +.more-icon { + width: 48rpx; + height: 48rpx; +} + +/* 内容滚动区域 */ +.content-scroll { + min-height: 100vh; + padding-bottom: 200rpx; +} + +/* 状态卡片 */ +.status-card { + margin: 32rpx; + background: #fff; + border-radius: 60rpx; + padding: 80rpx 48rpx; + text-align: center; + box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05); +} + +.status-icon { + width: 200rpx; + height: 200rpx; + margin: 0 auto 40rpx; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.status-icon.pending, +.status-icon.reviewing { + background: #FFF3E0; +} + +.status-icon.approved { + background: #E8F5E9; +} + +.status-icon.rejected { + background: #FFEBEE; +} + +.status-icon image { + width: 100rpx; + height: 100rpx; +} + +.status-title { + display: block; + font-size: 44rpx; + font-weight: 700; + color: #1F2937; + margin-bottom: 20rpx; +} + +.status-desc { + display: block; + font-size: 30rpx; + color: #9CA3AF; + margin-bottom: 48rpx; + line-height: 1.5; +} + +.btn-primary { + background: linear-gradient(135deg, #b06ab3 0%, #d4a5d6 100%); + color: #fff; + font-size: 34rpx; + font-weight: 700; + padding: 28rpx 80rpx; + border-radius: 60rpx; + border: none; +} + +.btn-secondary { + background: #F3F4F6; + color: #4B5563; + font-size: 34rpx; + font-weight: 700; + padding: 28rpx 80rpx; + border-radius: 60rpx; + border: none; +} + +/* 申请表单 */ +.apply-form { + margin: 16rpx 0 0; + background: #fff; + border-radius: 60rpx 60rpx 0 0; + padding: 64rpx 48rpx; + min-height: calc(100vh - 200rpx); +} + +/* 表单头部 */ +.form-header { + text-align: center; + margin-bottom: 64rpx; +} + +.form-title { + display: block; + font-size: 44rpx; + font-weight: 700; + color: #1F2937; + margin-bottom: 16rpx; +} + +.form-subtitle { + display: block; + font-size: 28rpx; + color: #9CA3AF; +} + +/* 表单区块 */ +.form-section { + margin-bottom: 64rpx; +} + +.section-header { + display: flex; + align-items: center; + margin-bottom: 32rpx; +} + +.section-title { + font-size: 34rpx; + font-weight: 700; + color: #1F2937; +} + +.required { + color: #F87171; + margin-left: 4rpx; + font-size: 34rpx; +} + +/* 头像上传 */ +.avatar-upload-area { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 24rpx; +} + +.avatar-circle { + width: 224rpx; + height: 224rpx; + border-radius: 50%; + background: #F3F4F6; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.avatar-image { + width: 100%; + height: 100%; +} + +.upload-placeholder { + display: flex; + flex-direction: column; + align-items: center; + gap: 8rpx; +} + +.camera-icon { + width: 64rpx; + height: 64rpx; + opacity: 0.6; +} + +.upload-text { + font-size: 24rpx; + color: #9CA3AF; +} + +.form-tip { + display: block; + text-align: center; + font-size: 24rpx; + color: #9CA3AF; +} + +/* 表单项 */ +.form-item { + margin-bottom: 40rpx; +} + +.item-label-row { + display: flex; + align-items: center; + margin-bottom: 16rpx; +} + +.item-label { + font-size: 30rpx; + color: #374151; +} + +.input-wrapper { + background: #F9FAFB; + border-radius: 28rpx; + padding: 0 32rpx; + height: 100rpx; + display: flex; + align-items: center; +} + +.item-input { + width: 100%; + height: 100%; + font-size: 30rpx; + color: #1F2937; +} + +.placeholder { + color: #9CA3AF; +} + +/* 性别选择 */ +.gender-options { + display: flex; + gap: 32rpx; +} + +.gender-btn { + flex: 1; + height: 100rpx; + background: #F9FAFB; + border-radius: 28rpx; + display: flex; + align-items: center; + justify-content: center; + font-size: 30rpx; + color: #4B5563; + transition: all 0.3s; +} + +.gender-btn.active { + background: linear-gradient(135deg, #b06ab3 0%, #d4a5d6 100%); + color: #fff; +} + +/* 服务类型 */ +.service-types { + display: flex; + gap: 24rpx; +} + +.service-btn { + flex: 1; + height: 88rpx; + background: #F9FAFB; + border-radius: 28rpx; + display: flex; + align-items: center; + justify-content: center; + font-size: 26rpx; + color: #4B5563; + transition: all 0.3s; +} + +.service-btn.active { + background: #FCE7F3; + color: #b06ab3; + border: 2rpx solid #b06ab3; +} + +/* 个人介绍 */ +.textarea-wrapper { + background: #F9FAFB; + border-radius: 28rpx; + padding: 32rpx; +} + +.intro-textarea { + width: 100%; + height: 320rpx; + font-size: 30rpx; + color: #1F2937; + line-height: 1.5; +} + +.textarea-footer { + display: flex; + justify-content: flex-end; + margin-top: 16rpx; +} + +.char-count { + font-size: 24rpx; + color: #9CA3AF; +} + +/* 协议 */ +.agreement-row { + display: flex; + align-items: center; + gap: 16rpx; + margin: 48rpx 0; +} + +.checkbox { + width: 40rpx; + height: 40rpx; + border: 2rpx solid #D1D5DC; + border-radius: 8rpx; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.checkbox.checked { + background: #b06ab3; + border-color: #b06ab3; +} + +.check-icon { + width: 24rpx; + height: 24rpx; +} + +.agreement-text { + display: flex; + flex-wrap: wrap; + align-items: center; +} + +.normal-text { + font-size: 26rpx; + color: #4B5563; +} + +.link-text { + font-size: 26rpx; + color: #BE185D; +} + +/* 提交按钮 */ +.submit-btn { + width: 100%; + height: 108rpx; + background: linear-gradient(135deg, #b06ab3 0%, #d4a5d6 100%); + color: #fff; + font-size: 36rpx; + font-weight: 700; + border-radius: 60rpx; + border: none; + display: flex; + align-items: center; + justify-content: center; +} + +.submit-btn.disabled { + background: #F3F4F6; + color: #D1D5DB; +} + +/* 底部占位 */ +.bottom-placeholder { + height: 120rpx; +} + +/* 浮动按钮 */ +.float-btn { + position: fixed; + bottom: 140rpx; + left: 50%; + transform: translateX(-50%); + background: rgba(255, 255, 255, 0.9); + border: 2rpx solid rgba(255, 107, 107, 0.2); + border-radius: 100rpx; + padding: 24rpx 50rpx; + display: flex; + align-items: center; + gap: 16rpx; + box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.1), 0 20rpx 30rpx rgba(0, 0, 0, 0.1); + z-index: 100; +} + +.float-btn-text { + font-size: 28rpx; + font-weight: 700; + color: #FF6B6B; +} + +.float-btn-arrow { + width: 32rpx; + height: 32rpx; +} diff --git a/pages/companion-chat/companion-chat.js b/pages/companion-chat/companion-chat.js new file mode 100644 index 0000000..ad3fe07 --- /dev/null +++ b/pages/companion-chat/companion-chat.js @@ -0,0 +1,751 @@ +// pages/companion-chat/companion-chat.js - 陪聊师列表/陪聊聊天页面 +// 对接后端API + +const api = require('../../utils/api') +const { getPageAssets } = require('../../utils/assets') +const app = getApp() + +Page({ + data: { + statusBarHeight: 44, + navBarHeight: 44, + totalNavHeight: 88, + stickyHeaderHeight: 150, + isSticky: false, + stickyThreshold: 0, + loading: false, + showCategoryGrid: true, + categoryImageLoadCount: 0, + categoryImageErrorCount: 0, + // 页面素材 + bannerImage: 'https://ai-c.maimanji.com/images/Header-banner.png', + categoryImages: [ + 'https://ai-c.maimanji.com/images/pb01.png', + 'https://ai-c.maimanji.com/images/pb02.png', + 'https://ai-c.maimanji.com/images/pb03.png', + 'https://ai-c.maimanji.com/images/pb04.png' + ], + consultButtonImage: '/images/btn-text-consult.png', + giftIcon: '/images/icon-gift.png', + locationIcon: '/images/icon-location.png', + // 页面模式: list(陪聊师列表) / chat(聊天) + mode: 'list', + // 列表模式数据 + searchKeyword: '', + counselorList: [], + filters: { + status: '', + gender: '', + sortBy: 'rating', + specialty: '' + }, + // 筛选条显示文本 + filterLabels: { + sort: '排序', + gender: '性别', + specialty: '类型', + filter: '筛选' + }, + page: 1, + hasMore: true, + // 聊天模式数据 + orderId: '', + companionId: '', + companionName: '', + messages: [], + inputText: '', + serviceEndTime: null, + remainingTime: '', + // 未读消息数 + totalUnread: 0, + // 电话倾诉指南弹窗 + showGuidePopup: false, + guideData: { + title: '电话倾诉指南', + subtitle: '让沟通更有效', + steps: [ + { number: 1, title: '选择合适的倾诉师', description: '根据您的需求,浏览倾诉师的擅长方向、服务经验和用户评价,选择最适合您的专业倾诉师。' }, + { number: 2, title: '预约通话时间', description: '选择您方便的时间段进行预约,确保有充足的时间进行深入交流,建议每次通话30-60分钟。' }, + { number: 3, title: '准备倾诉内容', description: '提前整理您想要倾诉的问题或困惑,这样能让沟通更有针对性和效果。' }, + { number: 4, title: '保持真诚开放', description: '在安全私密的环境中,真诚地表达您的感受和想法。倾诉师会为您保密,请放心倾诉。' } + ], + tips: { + title: '温馨提示', + content: '首次通话建议先试听,了解倾诉师的沟通风格。如遇紧急情况,请及时拨打心理危机热线或就医。' + } + }, + auditStatus: 0 + }, + + async onLoad(options) { + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + const menuButton = wx.getMenuButtonBoundingClientRect() + const navBarHeight = menuButton.height + (menuButton.top - statusBarHeight) * 2 + const totalNavHeight = statusBarHeight + navBarHeight + + // 计算固定头部高度(搜索框 + 筛选条) + const stickyHeaderHeight = statusBarHeight + 90 + + // 计算吸顶触发阈值:banner高度(400rpx) + 红包条(约80rpx) + 分类按钮(约230rpx) + 标题行(约80rpx) + // 转换为px:rpx * 屏幕宽度 / 750 + const screenWidth = systemInfo.screenWidth + const stickyThreshold = (400 + 80 + 230 + 80) * screenWidth / 750 + + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight, + stickyHeaderHeight, + stickyThreshold + }) + + // 加载页面素材 + await this.loadPageAssets() + + // 判断页面模式 + if (options.orderId) { + // 聊天模式 + this.setData({ + mode: 'chat', + orderId: options.orderId, + companionId: options.companionId, + companionName: decodeURIComponent(options.name || '') + }) + this.loadChatHistory() + } else { + // 列表模式 + this.loadCounselorList() + } + }, + + onShow() { + wx.hideTabBar({ animation: false }) + const app = getApp() + this.setData({ + auditStatus: app.globalData.auditStatus + }) + this.loadUnreadCount() + }, + + onUnload() { + // 清除计时器 + if (this.timer) { + clearInterval(this.timer) + } + }, + + /** + * 页面滚动监听 - 控制吸顶效果 + */ + onPageScroll(e) { + const scrollTop = e.detail.scrollTop + const { stickyThreshold, isSticky } = this.data + + // 添加一定的缓冲区,避免在临界点频繁切换 + const buffer = 10 // 10px缓冲区 + + // 当滚动超过阈值+缓冲时显示吸顶,低于阈值-缓冲时隐藏 + let shouldSticky = isSticky + if (scrollTop > stickyThreshold + buffer) { + shouldSticky = true + } else if (scrollTop < stickyThreshold - buffer) { + shouldSticky = false + } + + if (shouldSticky !== isSticky) { + this.setData({ isSticky: shouldSticky }) + } + }, + + /** + * 加载页面素材配置 + */ + async loadPageAssets() { + try { + const assets = await getPageAssets(); + + if (assets) { + this.setData({ + bannerImage: assets.banners.companion_banner, + categoryImages: [ + assets.entries.entry_1, + assets.entries.entry_2, + assets.entries.entry_3, + assets.entries.entry_4 + ], + consultButtonImage: assets.icons.consult_button, + giftIcon: assets.icons.gift, + locationIcon: assets.icons.location + }); + } + } catch (error) { + console.error('加载页面素材失败:', error); + // 使用默认值,已在 data 中定义 + } + }, + + /** + * 加载未读消息数 + */ + async loadUnreadCount() { + if (!app.globalData.isLoggedIn) { + this.setData({ totalUnread: 0 }) + return + } + + try { + const res = await api.chat.getConversations() + if (res.success && res.data) { + const totalUnread = res.data.reduce((sum, conv) => sum + (conv.unread_count || 0), 0) + this.setData({ totalUnread }) + } else { + this.setData({ totalUnread: 0 }) + } + } catch (err) { + console.log('获取未读消息数失败', err) + this.setData({ totalUnread: 0 }) + } + }, + + // ==================== 列表模式 ==================== + + /** + * 加载陪聊师列表 + */ + async loadCounselorList() { + this.setData({ loading: true, page: 1 }) + + try { + const params = { + page: 1, + pageSize: 20, + ...this.data.filters + } + + // 过滤掉空值参数 + Object.keys(params).forEach(key => { + if (params[key] === '' || params[key] === undefined || params[key] === null) { + delete params[key] + } + }) + + if (this.data.searchKeyword.trim()) { + params.keyword = this.data.searchKeyword.trim() + } + + console.log('请求陪聊师列表,参数:', params) + + const res = await api.companion.getList(params) + + console.log('陪聊师列表响应:', res) + + // 兼容两种返回格式 + let list = [] + if (res.success && res.data) { + // 格式1: { success: true, data: { list: [...] } } + // 格式2: { success: true, data: [...] } + list = Array.isArray(res.data) ? res.data : (res.data.list || []) + } + + if (list.length > 0) { + const transformedList = list.map(c => this.transformCounselor(c)) + + this.setData({ + counselorList: transformedList, + hasMore: list.length >= 20, + loading: false + }) + console.log('更新列表成功,数量:', transformedList.length) + } else { + // API没有数据时使用模拟数据 + console.log('API返回空数据,使用模拟数据') + this.setData({ + counselorList: this.getMockCounselorList(), + loading: false + }) + } + } catch (err) { + console.error('加载陪聊师列表失败', err) + // 加载失败时使用模拟数据 + this.setData({ + counselorList: this.getMockCounselorList(), + loading: false + }) + } + }, + + /** + * 获取模拟陪聊师数据 + */ + getMockCounselorList() { + return [ + { + id: 'c001', + name: '林心怡', + avatar: '', + avatarColor: '#e8b4d8', + avatarColorEnd: '#c984cd', + type: '文字/语音', + age: '28岁', + education: '心理学硕士', + training: '国家二级心理咨询师', + certification: '情感咨询专家认证', + quote: '每一次倾诉,都是心灵的释放', + tags: ['情感倾诉', '婚姻家庭', '亲密关系'], + serviceCount: 1286, + repeatCount: 423, + rating: 4.96, + location: '北京', + online: true + }, + { + id: 'c002', + name: '张明辉', + avatar: '', + avatarColor: '#a8d8ea', + avatarColorEnd: '#6bb3d9', + type: '文字/语音', + age: '35岁', + education: '应用心理学博士', + training: '高级心理咨询师', + certification: '职场心理专家', + quote: '用专业的态度,温暖每一颗心', + tags: ['职场压力', '人际关系', '情绪管理'], + serviceCount: 2156, + repeatCount: 687, + rating: 4.92, + location: '上海', + online: true + }, + { + id: 'c003', + name: '王雨萱', + avatar: '', + avatarColor: '#f8c8dc', + avatarColorEnd: '#e89bb8', + type: '文字/语音', + age: '26岁', + education: '心理学学士', + training: '情感咨询师', + certification: '青年心理辅导员', + quote: '倾听你的故事,陪伴你的成长', + tags: ['恋爱指导', '分手挽回', '单身脱单'], + serviceCount: 856, + repeatCount: 298, + rating: 4.89, + location: '深圳', + online: false + }, + { + id: 'c004', + name: '李思远', + avatar: '', + avatarColor: '#b8d4e3', + avatarColorEnd: '#8ab4cf', + type: '文字/语音', + age: '42岁', + education: '临床心理学硕士', + training: '资深心理治疗师', + certification: '家庭治疗师认证', + quote: '专业倾听,用心陪伴每一刻', + tags: ['婚姻危机', '家庭矛盾', '亲子教育'], + serviceCount: 3421, + repeatCount: 1156, + rating: 4.98, + location: '广州', + online: true + }, + { + id: 'c005', + name: '陈晓琳', + avatar: '', + avatarColor: '#d4b8e8', + avatarColorEnd: '#b088d4', + type: '文字/语音', + age: '31岁', + education: '发展心理学硕士', + training: '心理咨询师', + certification: '情绪管理专家', + quote: '让每一次对话都充满温暖', + tags: ['焦虑抑郁', '情绪调节', '自我成长'], + serviceCount: 1567, + repeatCount: 512, + rating: 4.94, + location: '杭州', + online: true + }, + { + id: 'c006', + name: '赵文博', + avatar: '', + avatarColor: '#a8e6cf', + avatarColorEnd: '#7bc9a6', + type: '文字/语音', + age: '38岁', + education: '社会心理学博士', + training: '高级心理顾问', + certification: '企业EAP咨询师', + quote: '理性分析,感性陪伴', + tags: ['职业规划', '压力管理', '领导力'], + serviceCount: 1892, + repeatCount: 634, + rating: 4.91, + location: '成都', + online: false + } + ] + }, + + /** + * 转换陪聊师数据格式 + */ + transformCounselor(data) { + const config = require('../../config/index') + + // 处理地址,只显示城市名 + let location = data.location || data.city || '' + if (location) { + // 如果地址包含多个部分(如"北京 北京市 朝阳区"),只取第一个城市名 + // 或者如果是"XX市"格式,去掉"市"字 + const parts = location.split(/[\s,,]+/).filter(p => p.trim()) + if (parts.length > 0) { + location = parts[0].replace(/[省市区县]$/, '') || parts[0] + } + } + + // 处理在线状态 - 后端返回 onlineStatus 或 status + const onlineStatus = data.onlineStatus || data.online_status || data.status || 'offline' + const isOnline = onlineStatus === 'online' + const isBusy = onlineStatus === 'busy' + + // 处理头像URL - 如果是相对路径,拼接完整域名 + let avatar = data.avatar || '' + if (avatar && avatar.startsWith('/')) { + // 从 API_BASE_URL 提取域名(去掉 /api 后缀) + const baseUrl = config.API_BASE_URL.replace(/\/api$/, '') + avatar = baseUrl + avatar + } + + return { + id: data.id, + name: data.name || data.displayName || data.nickname, + avatar: avatar, + type: data.service_type || '文字/语音', + age: data.age_group || data.age || '', + education: data.education || '', + training: data.training || data.certification || '', + certification: data.certification || '', + quote: data.quote || data.bio || data.introduction || '', + tags: data.tags || data.specialties || [], + serviceCount: data.serviceCount || data.service_count || data.totalOrders || 0, + repeatCount: data.repeatCount || data.repeat_count || 0, + rating: data.rating || 5.0, + location: location, + online: isOnline, + onlineStatus: onlineStatus, + statusText: data.statusText || (isOnline ? '在线' : isBusy ? '忙碌中' : '离线'), + // 等级和价格信息 + levelCode: data.levelCode || 'junior', + levelName: data.levelName || '初级', + textPrice: data.textPrice || data.pricePerMinute || 0.5, + voicePrice: data.voicePrice || 1 + } + }, + + onSearchInput(e) { + this.setData({ searchKeyword: e.detail.value }) + }, + + /** + * 搜索 + */ + onSearch() { + this.loadCounselorList() + }, + + onCategoryTap(e) { + const type = e.currentTarget.dataset.type + wx.showToast({ title: type, icon: 'none' }) + }, + + /** + * 分类图片加载成功 + */ + onCategoryImageLoad() { + const count = this.data.categoryImageLoadCount + 1 + this.setData({ categoryImageLoadCount: count }) + }, + + /** + * 分类图片加载失败 - 如果全部失败则隐藏区域 + */ + onCategoryImageError() { + const errorCount = this.data.categoryImageErrorCount + 1 + this.setData({ categoryImageErrorCount: errorCount }) + + // 如果4张图片都加载失败,隐藏整个分类区域 + if (errorCount >= 4) { + this.setData({ showCategoryGrid: false }) + } + }, + + onFilterTap(e) { + const filter = e.currentTarget.dataset.filter + console.log('筛选点击:', filter) + + const options = this.getFilterOptions(filter) + console.log('筛选选项:', options) + + if (!options || options.length === 0) { + wx.showToast({ title: '暂无筛选选项', icon: 'none' }) + return + } + + wx.showActionSheet({ + itemList: options, + success: (res) => { + console.log('选择了:', res.tapIndex, options[res.tapIndex]) + const value = this.getFilterValue(filter, res.tapIndex) + const label = options[res.tapIndex] + + // 更新筛选值 - 映射筛选类型到正确的参数名 + // sort -> sortBy, filter -> status, 其他保持不变 + const filterKeyMap = { + sort: 'sortBy', + filter: 'status', // 筛选选项对应后端的 status 参数 + gender: 'gender', + specialty: 'specialty' + } + const filterKey = filterKeyMap[filter] || filter + + // 更新筛选标签显示 + const defaultLabels = { + sort: '排序', + gender: '性别', + specialty: '类型', + filter: '筛选' + } + const displayLabel = res.tapIndex === 0 ? defaultLabels[filter] : label + + console.log('更新筛选参数:', filterKey, '=', value) + + this.setData({ + [`filters.${filterKey}`]: value, + [`filterLabels.${filter}`]: displayLabel + }) + + this.loadCounselorList() + }, + fail: (err) => { + console.log('取消选择或失败:', err) + } + }) + }, + + getFilterOptions(filter) { + const options = { + sort: ['综合排序', '好评优先', '服务人次', '价格从低到高'], + gender: ['不限', '男', '女'], + specialty: ['不限', '情感倾诉', '婚姻家庭', '职场压力', '亲子关系'], + filter: ['在线优先', '有空闲', '全部'] + } + return options[filter] || [] + }, + + getFilterValue(filter, index) { + const values = { + sort: ['', 'rating', 'service_count', 'price'], + gender: ['', 'male', 'female'], + specialty: ['', 'emotion', 'marriage', 'work', 'family'], + filter: ['online', 'available', ''] + } + return (values[filter] || [])[index] || '' + }, + + async onGuideClick() { + // 显示弹窗 + this.setData({ showGuidePopup: true }) + + // 从后台获取协议内容 + try { + const res = await api.agreement.get('phone-guide') + if (res.success && res.data && res.data.content) { + // 解析JSON内容 + let content = res.data.content + if (typeof content === 'string') { + try { + content = JSON.parse(content) + } catch (e) { + console.log('协议内容不是JSON格式') + return + } + } + + // 更新弹窗数据 + this.setData({ + 'guideData.subtitle': content.subtitle || '让沟通更有效', + 'guideData.steps': content.steps || this.data.guideData.steps, + 'guideData.tips': content.tips || this.data.guideData.tips + }) + } + } catch (err) { + console.log('获取协议内容失败,使用默认内容', err) + } + }, + + /** + * 关闭指南弹窗 + */ + onCloseGuidePopup() { + this.setData({ showGuidePopup: false }) + }, + + /** + * 阻止弹窗内容区域的点击事件冒泡 + */ + preventClose() { + // 空函数,阻止事件冒泡 + }, + + onCounselorTap(e) { + const id = e.currentTarget.dataset.id + wx.navigateTo({ + url: `/pages/counselor-detail/counselor-detail?id=${id}` + }) + }, + + /** + * 头像加载失败时,清空avatar字段让其显示占位符 + */ + onAvatarError(e) { + const index = e.currentTarget.dataset.index + if (index !== undefined) { + this.setData({ + [`counselorList[${index}].avatar`]: '' + }) + } + }, + + onTrialListen(e) { + const id = e.currentTarget.dataset.id + wx.showToast({ title: '正在播放试听...', icon: 'none' }) + }, + + onConsult(e) { + const id = e.currentTarget.dataset.id + wx.navigateTo({ + url: `/pages/counselor-detail/counselor-detail?id=${id}` + }) + }, + + // ==================== 聊天模式 ==================== + + /** + * 加载聊天历史 + */ + async loadChatHistory() { + this.setData({ loading: true }) + + try { + const res = await api.chat.getChatHistory(this.data.orderId, { + page: 1, + limit: 50 + }) + + if (res.success && res.data) { + const messages = (res.data || []).map(msg => ({ + id: msg.id, + content: msg.content, + isMe: msg.sender_type === 'user', + time: this.formatTime(msg.created_at), + type: msg.message_type || 'text' + })) + + this.setData({ messages, loading: false }) + this.scrollToBottom() + } else { + this.setData({ loading: false }) + } + } catch (err) { + console.error('加载聊天历史失败', err) + this.setData({ loading: false }) + } + }, + + /** + * 发送消息 + */ + async sendMessage() { + const { inputText, orderId } = this.data + + if (!inputText.trim()) return + + const messageText = inputText.trim() + + // 添加到消息列表 + const newMessage = { + id: Date.now(), + content: messageText, + isMe: true, + time: this.formatTime(new Date()), + type: 'text' + } + + this.setData({ + messages: [...this.data.messages, newMessage], + inputText: '' + }) + + this.scrollToBottom() + + try { + await api.companion.sendMessage({ + order_id: orderId, + message: messageText + }) + } catch (err) { + console.error('发送消息失败', err) + wx.showToast({ title: '发送失败', icon: 'none' }) + } + }, + + onInput(e) { + this.setData({ inputText: e.detail.value }) + }, + + /** + * 格式化时间 + */ + formatTime(dateStr) { + const date = new Date(dateStr) + const hour = String(date.getHours()).padStart(2, '0') + const minute = String(date.getMinutes()).padStart(2, '0') + return `${hour}:${minute}` + }, + + scrollToBottom() { + // 滚动到底部 + }, + + onBack() { + wx.navigateBack() + }, + + // Tab bar navigation - 需要登录的页面检查登录状态 + switchTab(e) { + const path = e.currentTarget.dataset.path + const app = getApp() + + // 消息和我的页面需要登录 + if (path === '/pages/chat/chat' || path === '/pages/profile/profile') { + if (!app.globalData.isLoggedIn) { + wx.navigateTo({ + url: '/pages/login/login?redirect=' + encodeURIComponent(path) + }) + return + } + } + wx.switchTab({ url: path }) + } +}) diff --git a/pages/companion-chat/companion-chat.json b/pages/companion-chat/companion-chat.json new file mode 100644 index 0000000..e90e996 --- /dev/null +++ b/pages/companion-chat/companion-chat.json @@ -0,0 +1,4 @@ +{ + "usingComponents": {}, + "navigationStyle": "custom" +} diff --git a/pages/companion-chat/companion-chat.wxml b/pages/companion-chat/companion-chat.wxml new file mode 100644 index 0000000..6c00f39 --- /dev/null +++ b/pages/companion-chat/companion-chat.wxml @@ -0,0 +1,236 @@ + + + + + + + + + + + + + + {{filterLabels.sort}} + + + + {{filterLabels.gender}} + + + + {{filterLabels.specialty}} + + + + {{filterLabels.filter}} + + + + + + + + + + + + + + + + 您有1个红包即将过期,请及时使用哦 + + + + + + + + + + + + + + + + + + + + + + + 倾听陪伴 + + 电话倾诉指南 + + + + + + + + + + + + + + {{filterLabels.sort}} + + + + {{filterLabels.gender}} + + + + {{filterLabels.specialty}} + + + + {{filterLabels.filter}} + + + + + + + + + + + + + + + + + {{item.name[0]}} + + + + {{item.statusText}} + + + 试听 + + + + + + + {{item.name}} + {{item.type}} + + {{item.age}} {{item.education}} {{item.training}} + {{item.certification}} + "{{item.quote}}" + + + {{tag}} + + + + + {{item.serviceCount}} + 服务人次 + + + {{item.rating}} + 评分 + + + + + + + + + + {{item.location}} + + + + + + + + + + + + + + + 陪伴 + + + + 文娱 + + + + 服务 + + + + + + 消息 + + + + 我的 + + + + + + + + + + + + + + {{guideData.title}} + {{guideData.subtitle}} + + + + + + + + + + + {{item.number}} + + {{item.title}} + {{item.description}} + + + + + + + + ! + + + {{guideData.tips.title}} + {{guideData.tips.content}} + + + + + + + + + + diff --git a/pages/companion-chat/companion-chat.wxss b/pages/companion-chat/companion-chat.wxss new file mode 100644 index 0000000..2629a46 --- /dev/null +++ b/pages/companion-chat/companion-chat.wxss @@ -0,0 +1,817 @@ +/* 陪聊页面样式 */ +page { + background: #fff; +} + +.page-container { + min-height: 100vh; + background: #fff; + position: relative; +} + +/* 吸顶搜索和筛选区域 */ +.sticky-header { + position: fixed; + top: 0; + left: 0; + right: 0; + width: 100%; + z-index: 998; + background-color: #ffffff; + padding: 16rpx 32rpx 24rpx; + padding-top: calc(16rpx + var(--status-bar-height, 44px)); + box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08); + opacity: 0; + pointer-events: none; + visibility: hidden; +} + +.sticky-header::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ffffff; + z-index: -1; +} + +.sticky-header.show { + opacity: 1; + pointer-events: auto; + visibility: visible; +} + +/* 吸顶内的搜索框 */ +.sticky-header .search-box { + background: #f3f4f6; + position: relative; + z-index: 1; +} + +/* 吸顶内的筛选条 */ +.sticky-header .filter-bar { + background: transparent; + position: relative; + z-index: 1; +} + +/* 跟随滚动的 Header 背景 */ +.header-scroll { + height: 400rpx; + overflow: hidden; + /* 备用渐变背景,当图片加载失败时显示 */ + background: linear-gradient(135deg, #e8d5f0 0%, #f5e6d3 50%, #fce4ec 100%); +} + +.header-image { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* 隐藏滚动条 */ +.content-scroll::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} + +.content-scroll { + -ms-overflow-style: none; + scrollbar-width: none; + height: 100vh; + box-sizing: border-box; + position: relative; + z-index: 1; +} + +/* 红包提示条 */ +.notice-bar { + padding: 24rpx 32rpx; + background: linear-gradient(to right, #fffbeb, #fff7ed); + border-bottom: 2rpx solid rgba(254, 243, 198, 0.5); + display: flex; + align-items: center; + gap: 16rpx; +} + +.notice-icon { + width: 40rpx; + height: 40rpx; +} + +.notice-text { + font-size: 32rpx; + color: #973c00; +} + +/* 分类按钮 */ +.category-grid { + display: flex; + gap: 16rpx; + padding: 24rpx 32rpx; +} + +.category-btn { + flex: 1; + height: 180rpx; + border-radius: 24rpx; + overflow: hidden; + position: relative; +} + +.category-image { + width: 100%; + height: 100%; + border-radius: 24rpx; +} + +/* 倾诉师列表区域 */ +.counselor-section { + padding: 24rpx 32rpx; + background: #fff; + border-radius: 48rpx 48rpx 0 0; + margin-top: -24rpx; +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 32rpx; +} + +.section-title { + font-size: 40rpx; + font-weight: 700; + color: #1e2939; +} + +.guide-btn { + display: flex; + align-items: center; + gap: 4rpx; +} + +.guide-text { + font-size: 24rpx; + color: #101828; +} + +.guide-arrow { + width: 24rpx; + height: 24rpx; +} + +/* 搜索框 */ +.search-box { + display: flex; + align-items: center; + background: #f3f4f6; + border-radius: 100rpx; + padding: 20rpx 32rpx; + margin-bottom: 16rpx; +} + +.search-box.hidden { + display: none; +} + +.search-icon { + width: 40rpx; + height: 40rpx; + margin-right: 16rpx; +} + +.search-input { + flex: 1; + font-size: 28rpx; + color: #101828; +} + +.search-input::placeholder { + color: rgba(16, 24, 40, 0.5); +} + +/* 筛选条 */ +.filter-bar { + display: flex; + justify-content: space-between; + padding-bottom: 0; + border-bottom: none; + margin-bottom: 0; +} + +.filter-bar-inline { + padding-bottom: 24rpx; + border-bottom: 2rpx solid #f3f4f6; + margin-bottom: 24rpx; +} + +.filter-bar.hidden { + display: none; +} + +.filter-item { + display: flex; + align-items: center; + gap: 4rpx; + padding: 16rpx 8rpx; + min-height: 60rpx; +} + +.filter-item.active .filter-text { + color: #9333ea; + font-weight: 500; +} + +.filter-text { + font-size: 32rpx; + color: #364153; +} + +.filter-arrow { + width: 32rpx; + height: 32rpx; + opacity: 0.6; +} + +/* 吸顶占位 */ +.sticky-placeholder { + height: 0; +} + +.sticky-placeholder.show { + height: 180rpx; +} + +/* 倾诉师列表 */ +.counselor-list { + display: flex; + flex-direction: column; +} + +.counselor-card { + display: flex; + padding: 16rpx 0; + border-bottom: 2rpx solid #f3f4f6; + position: relative; +} + +.counselor-card:last-child { + border-bottom: none; +} + +/* 左侧头像区域 */ +.counselor-avatar-section { + display: flex; + flex-direction: column; + align-items: center; + width: 120rpx; + margin-right: 16rpx; + flex-shrink: 0; +} + +.avatar-wrap { + width: 120rpx; + height: 120rpx; + border-radius: 50%; + background: #e5e7eb; + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.avatar-placeholder { + width: 100%; + height: 100%; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +/* 真实头像图片样式 */ +.avatar-image { + width: 100%; + height: 100%; + border-radius: 50%; +} + +.avatar-text { + font-size: 40rpx; + font-weight: 700; + color: #fff; +} + +.online-dot { + position: absolute; + bottom: 4rpx; + right: 4rpx; + width: 24rpx; + height: 24rpx; + background: #22c55e; + border-radius: 50%; + border: 4rpx solid #fff; + box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1); +} + +/* 忙碌状态 - 橙色 */ +.online-dot.busy { + background: #f59e0b; +} + +/* 状态文字 */ +.status-text { + font-size: 22rpx; + margin-top: 8rpx; + padding: 4rpx 12rpx; + border-radius: 20rpx; + text-align: center; +} + +.status-text.online { + color: #22c55e; + background: rgba(34, 197, 94, 0.1); +} + +.status-text.busy { + color: #f59e0b; + background: rgba(245, 158, 11, 0.1); +} + +.status-text.offline { + color: #9ca3af; + background: rgba(156, 163, 175, 0.1); +} + +.trial-btn { + display: flex; + align-items: center; + gap: 8rpx; + background: #eff6ff; + border-radius: 100rpx; + padding: 8rpx 16rpx; + margin-top: 16rpx; +} + +.play-icon { + width: 0; + height: 0; + border-left: 10rpx solid #2b7fff; + border-top: 6rpx solid transparent; + border-bottom: 6rpx solid transparent; +} + +.trial-text { + font-size: 28rpx; + color: #155dfc; +} + +.location-row { + display: inline-flex; + align-items: center; + gap: 4rpx; + margin-top: 4rpx; + float: right; +} + +.location-icon { + width: 24rpx; + height: 24rpx; + flex-shrink: 0; +} + +.location-text { + font-size: 26rpx; + color: #6a7282; +} + +/* 中间信息区域 */ +.counselor-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 6rpx; + min-width: 0; + padding-right: 136rpx; +} + +.name-row { + display: flex; + align-items: center; + gap: 16rpx; +} + +.counselor-name { + font-size: 40rpx; + font-weight: 700; + color: #101828; +} + +.counselor-type { + font-size: 28rpx; + color: #6a7282; +} + +.counselor-desc { + font-size: 28rpx; + color: #6a7282; + line-height: 1.5; +} + +.counselor-cert { + font-size: 28rpx; + color: #4a5565; +} + +.counselor-quote { + font-size: 28rpx; + color: #009966; + font-style: normal; + line-height: 1.5; + margin: 8rpx 0; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; + text-overflow: ellipsis; +} + +.tags-row { + display: flex; + gap: 12rpx; + flex-wrap: wrap; +} + +.tag { + background: #f9fafb; + border: 2rpx solid #f3f4f6; + border-radius: 16rpx; + padding: 4rpx 16rpx; +} + +.tag-text { + font-size: 28rpx; + color: #6a7282; +} + +.stats-row { + display: flex; + gap: 16rpx; + margin-top: 8rpx; + flex-wrap: nowrap; + align-items: center; +} + +.stat-item { + display: flex; + align-items: center; + gap: 4rpx; + white-space: nowrap; +} + +.stat-value { + font-size: 28rpx; + font-weight: 700; + color: #4a5565; +} + +.stat-label { + font-size: 28rpx; + color: #99a1af; +} + +/* 右侧咨询按钮和地址 */ +.counselor-action { + position: absolute; + top: 16rpx; + right: 0; + bottom: 16rpx; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; +} + +.consult-btn { + width: 120rpx; + height: 120rpx; +} + +.counselor-action .location-row { + display: flex; + align-items: center; + gap: 4rpx; +} + +.location-icon { + width: 24rpx; + height: 24rpx; + flex-shrink: 0; +} + +.location-text { + font-size: 26rpx; + color: #6a7282; +} + +/* 底部占位 */ +.bottom-placeholder { + height: 240rpx; +} + +/* 自定义底部导航栏 - 完全匹配Figma设计 */ +.custom-tabbar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 194rpx; + background: #fff; + display: flex; + align-items: flex-start; + justify-content: space-around; + padding-top: 24rpx; + z-index: 999; + border-top: 2rpx solid #F3F4F6; +} + +.tabbar-item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12rpx; + width: 150rpx; + height: 120rpx; +} + +.tabbar-icon { + width: 68rpx; + height: 68rpx; +} + +.tabbar-text { + font-family: Arial, sans-serif; + font-size: 40rpx; + font-weight: 700; + color: #A58AA5; + line-height: 1; +} + +.tabbar-text.active { + color: #B06AB3; +} + +.message-icon-wrapper { + position: relative; + width: 68rpx; + height: 68rpx; +} + +.message-icon-wrapper .tabbar-icon { + width: 68rpx; + height: 68rpx; +} + +.message-dot { + position: absolute; + top: -8rpx; + right: -8rpx; + width: 24rpx; + height: 24rpx; + background: #FB2C36; + border: 2rpx solid #fff; + border-radius: 50%; +} + + +/* ==================== 电话倾诉指南弹窗样式 ==================== */ + +/* 遮罩层 */ +.guide-popup-mask { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} + +/* 弹窗容器 */ +.guide-popup { + width: 680rpx; + background: #fff; + border-radius: 48rpx; + overflow: hidden; + animation: popupIn 0.3s ease-out; + box-shadow: 0 50rpx 100rpx -24rpx rgba(0, 0, 0, 0.25); +} + +@keyframes popupIn { + from { + opacity: 0; + transform: scale(0.9); + } + to { + opacity: 1; + transform: scale(1); + } +} + +/* 紫色渐变头部 */ +.guide-header { + background: linear-gradient(180deg, #b06ab3 0%, #c984cd 100%); + padding: 40rpx 48rpx; + display: flex; + align-items: center; + justify-content: space-between; +} + +.guide-header-left { + display: flex; + align-items: center; + gap: 24rpx; +} + +.guide-phone-icon { + width: 96rpx; + height: 96rpx; + background: rgba(255, 255, 255, 0.2); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.phone-icon-img { + width: 48rpx; + height: 48rpx; +} + +.guide-header-text { + display: flex; + flex-direction: column; + gap: 4rpx; +} + +.guide-title { + font-size: 40rpx; + font-weight: 700; + color: #fff; + line-height: 1.4; +} + +.guide-subtitle { + font-size: 28rpx; + color: rgba(255, 255, 255, 0.8); + line-height: 1.4; +} + +.guide-close-btn { + width: 64rpx; + height: 64rpx; + background: rgba(255, 255, 255, 0.2); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.close-icon-img { + width: 40rpx; + height: 40rpx; +} + +/* 步骤列表 */ +.guide-steps { + padding: 40rpx 48rpx 0; +} + +.guide-step { + display: flex; + align-items: flex-start; + margin-bottom: 40rpx; + gap: 24rpx; +} + +.guide-step:last-child { + margin-bottom: 0; +} + +.step-number { + width: 64rpx; + height: 64rpx; + background: linear-gradient(180deg, #b06ab3 0%, #c984cd 100%); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 28rpx; + font-weight: 700; + color: #fff; + flex-shrink: 0; +} + +.step-content { + flex: 1; + padding-top: 4rpx; +} + +.step-title { + display: block; + font-size: 32rpx; + font-weight: 700; + color: #1e2939; + margin-bottom: 12rpx; + line-height: 1.5; +} + +.step-desc { + display: block; + font-size: 28rpx; + color: #4a5565; + line-height: 1.625; +} + +/* 温馨提示卡片 */ +.guide-tips { + margin: 40rpx 48rpx; + padding: 34rpx; + background: linear-gradient(180deg, #fffbeb 0%, #fff7ed 100%); + border: 2rpx solid #fde68a; + border-radius: 32rpx; + display: flex; + align-items: flex-start; + gap: 28rpx; +} + +.tips-icon-wrap { + width: 40rpx; + height: 40rpx; + background: #fbbf24; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-top: 4rpx; +} + +.tips-icon-text { + font-size: 24rpx; + font-weight: 700; + color: #fff; +} + +.tips-content { + flex: 1; +} + +.tips-title { + display: block; + font-size: 32rpx; + font-weight: 700; + color: #92400e; + margin-bottom: 8rpx; + line-height: 1.5; +} + +.tips-text { + display: block; + font-size: 28rpx; + color: #78350f; + line-height: 1.625; +} + +/* 确认按钮 */ +.guide-btn-wrap { + padding: 0 48rpx 40rpx; +} + +.guide-confirm-btn { + width: 100%; + height: 120rpx; + background: linear-gradient(180deg, #b06ab3 0%, #c984cd 100%); + border-radius: 32rpx; + font-size: 36rpx; + font-weight: 700; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + border: none; + box-shadow: 0 8rpx 12rpx -8rpx rgba(0, 0, 0, 0.1), 0 20rpx 30rpx -6rpx rgba(0, 0, 0, 0.1); +} + +.guide-confirm-btn::after { + border: none; +} diff --git a/pages/companion-orders/companion-orders.js b/pages/companion-orders/companion-orders.js new file mode 100644 index 0000000..ed3cf77 --- /dev/null +++ b/pages/companion-orders/companion-orders.js @@ -0,0 +1,307 @@ +// pages/companion-orders/companion-orders.js +const api = require('../../utils/api') + +Page({ + data: { + currentTab: 'all', + orders: [], + stats: { + totalOrders: 0, + completedOrders: 0, + totalIncome: '0.00' + }, + page: 1, + pageSize: 20, + hasMore: true, + loading: false, + // 评价回复弹窗 + showReplyModal: false, + currentReview: null, + replyContent: '', + submittingReply: false + }, + + onLoad() { + this.loadStats() + this.loadOrders() + }, + + onPullDownRefresh() { + this.setData({ page: 1, hasMore: true }) + Promise.all([this.loadStats(), this.loadOrders()]).finally(() => { + wx.stopPullDownRefresh() + }) + }, + + onReachBottom() { + if (this.data.hasMore && !this.data.loading) { + this.loadMoreOrders() + } + }, + + // 加载统计数据 + async loadStats() { + try { + const res = await api.companion.getOrderStats() + if (res.success) { + this.setData({ + stats: { + totalOrders: res.data.total_orders || 0, + completedOrders: res.data.completed_orders || 0, + totalIncome: (res.data.total_income || 0).toFixed(2) + } + }) + } + } catch (err) { + console.error('加载统计数据失败:', err) + } + }, + + // 加载订单列表 + async loadOrders() { + this.setData({ loading: true }) + try { + const params = { + page: 1, + pageSize: this.data.pageSize + } + if (this.data.currentTab !== 'all') { + params.status = this.data.currentTab + } + + const res = await api.companion.getOrders(params) + if (res.success) { + const orders = (res.data?.list || []).map(order => this.formatOrder(order)) + this.setData({ + orders, + page: 1, + hasMore: orders.length >= this.data.pageSize + }) + } + } catch (err) { + console.error('加载订单失败:', err) + } finally { + this.setData({ loading: false }) + } + }, + + // 加载更多订单 + async loadMoreOrders() { + this.setData({ loading: true }) + try { + const params = { + page: this.data.page + 1, + pageSize: this.data.pageSize + } + if (this.data.currentTab !== 'all') { + params.status = this.data.currentTab + } + + const res = await api.companion.getOrders(params) + if (res.success) { + const newOrders = (res.data?.list || []).map(order => this.formatOrder(order)) + this.setData({ + orders: [...this.data.orders, ...newOrders], + page: this.data.page + 1, + hasMore: newOrders.length >= this.data.pageSize + }) + } + } catch (err) { + console.error('加载更多订单失败:', err) + } finally { + this.setData({ loading: false }) + } + }, + + // 格式化订单数据 + formatOrder(order) { + return { + ...order, + statusText: this.getStatusText(order.status), + serviceTypeText: this.getServiceTypeText(order.service_type), + createTimeText: this.formatTime(order.created_at) + } + }, + + // 获取状态文本 + getStatusText(status) { + const statusMap = { + 'pending': '待服务', + 'in_progress': '进行中', + 'completed': '已完成', + 'cancelled': '已取消' + } + return statusMap[status] || status + }, + + // 获取服务类型文本 + getServiceTypeText(type) { + const typeMap = { + 'chat': '文字聊天', + 'voice': '语音聊天', + 'video': '视频聊天' + } + return typeMap[type] || '聊天服务' + }, + + // 格式化时间 + formatTime(timeStr) { + if (!timeStr) return '' + const date = new Date(timeStr) + const month = date.getMonth() + 1 + const day = date.getDate() + const hour = date.getHours().toString().padStart(2, '0') + const minute = date.getMinutes().toString().padStart(2, '0') + return `${month}/${day} ${hour}:${minute}` + }, + + // 切换标签 + switchTab(e) { + const tab = e.currentTarget.dataset.tab + if (tab === this.data.currentTab) return + + this.setData({ + currentTab: tab, + orders: [], + page: 1, + hasMore: true + }) + this.loadOrders() + }, + + // 开始服务 + async startService(e) { + const orderId = e.currentTarget.dataset.id + + wx.showModal({ + title: '开始服务', + content: '确定要开始服务吗?', + success: async (res) => { + if (res.confirm) { + wx.showLoading({ title: '处理中...' }) + try { + const result = await api.order.startService(orderId) + if (result.success) { + wx.showToast({ title: '服务已开始', icon: 'success' }) + this.loadOrders() + this.loadStats() + } else { + wx.showToast({ title: result.message || '操作失败', icon: 'none' }) + } + } catch (err) { + wx.showToast({ title: '操作失败', icon: 'none' }) + } finally { + wx.hideLoading() + } + } + } + }) + }, + + // 结束服务 + async endService(e) { + const orderId = e.currentTarget.dataset.id + + wx.showModal({ + title: '结束服务', + content: '确定要结束服务吗?', + success: async (res) => { + if (res.confirm) { + wx.showLoading({ title: '处理中...' }) + try { + const result = await api.order.endService(orderId) + if (result.success) { + wx.showToast({ title: '服务已结束', icon: 'success' }) + this.loadOrders() + this.loadStats() + } else { + wx.showToast({ title: result.message || '操作失败', icon: 'none' }) + } + } catch (err) { + wx.showToast({ title: '操作失败', icon: 'none' }) + } finally { + wx.hideLoading() + } + } + } + }) + }, + + // 跳转到聊天 + goToChat(e) { + const order = e.currentTarget.dataset.order + wx.navigateTo({ + url: `/pages/companion-chat/companion-chat?orderId=${order.id}&userId=${order.user_id}` + }) + }, + + // 跳转到订单详情 + goToDetail(e) { + const orderId = e.currentTarget.dataset.id + wx.navigateTo({ + url: `/pages/order-detail/order-detail?id=${orderId}` + }) + }, + + // 查看评价 + viewReview(e) { + const order = e.currentTarget.dataset.order + if (order.review) { + this.setData({ + currentReview: order.review, + showReplyModal: true, + replyContent: '' + }) + } + }, + + // 关闭回复弹窗 + closeReplyModal() { + this.setData({ + showReplyModal: false, + currentReview: null, + replyContent: '' + }) + }, + + // 输入回复内容 + onReplyInput(e) { + this.setData({ replyContent: e.detail.value }) + }, + + // 提交回复 + async submitReply() { + const { currentReview, replyContent } = this.data + + if (!replyContent.trim()) { + wx.showToast({ title: '请输入回复内容', icon: 'none' }) + return + } + + if (this.data.submittingReply) return + + this.setData({ submittingReply: true }) + wx.showLoading({ title: '提交中...' }) + + try { + const res = await api.companion.replyReview(currentReview.id, replyContent.trim()) + + wx.hideLoading() + this.setData({ submittingReply: false }) + + if (res.success) { + wx.showToast({ title: '回复成功', icon: 'success' }) + this.closeReplyModal() + // 刷新订单列表 + this.loadOrders() + } else { + wx.showToast({ title: res.message || res.error || '回复失败', icon: 'none' }) + } + } catch (err) { + wx.hideLoading() + this.setData({ submittingReply: false }) + console.error('回复评价失败', err) + wx.showToast({ title: '回复失败', icon: 'none' }) + } + } +}) diff --git a/pages/companion-orders/companion-orders.json b/pages/companion-orders/companion-orders.json new file mode 100644 index 0000000..914163f --- /dev/null +++ b/pages/companion-orders/companion-orders.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "我的订单", + "navigationBarBackgroundColor": "#E8C3D4", + "usingComponents": {} +} diff --git a/pages/companion-orders/companion-orders.wxml b/pages/companion-orders/companion-orders.wxml new file mode 100644 index 0000000..648ca4e --- /dev/null +++ b/pages/companion-orders/companion-orders.wxml @@ -0,0 +1,164 @@ + + + var DEFAULT_AVATAR = 'https://ai-c.maimanji.com/images/default-avatar.png'; + module.exports = { + getAvatar: function(avatar) { + return avatar || DEFAULT_AVATAR; + } + }; + + + + + + {{stats.totalOrders || 0}} + 总订单 + + + {{stats.completedOrders || 0}} + 已完成 + + + ¥{{stats.totalIncome || '0.00'}} + 总收入 + + + + + + + 全部 + + + 待服务 + + + 进行中 + + + 已完成 + + + + + + + + + + {{item.statusText}} + + + + + + {{item.serviceTypeText}} + {{item.duration}}分钟 + + + ¥{{item.amount}} + + + + + + + 用户评价 + + + + + {{item.review.content || '用户给了好评'}} + + 我的回复: + {{item.review.reply}} + + 点击回复 + + + + {{item.createTimeText}} + + + + + + + + + + + + + 暂无订单 + + + + + 加载中... + + + 没有更多了 + + + + + + + + 回复评价 + + × + + + + + + + + 用户评分: + + + + + + {{item}} + + {{currentReview.content || '用户给了好评'}} + + + + + 您已回复: + {{currentReview.reply}} + + + + + 输入回复内容 + + {{replyContent.length}}/200 + + + + + + + diff --git a/pages/companion-orders/companion-orders.wxss b/pages/companion-orders/companion-orders.wxss new file mode 100644 index 0000000..6745592 --- /dev/null +++ b/pages/companion-orders/companion-orders.wxss @@ -0,0 +1,488 @@ +/* pages/companion-orders/companion-orders.wxss */ +.container { + min-height: 100vh; + background: linear-gradient(180deg, #E8C3D4 0%, #F5E6EC 100%); + padding-bottom: 40rpx; +} + +/* 统计卡片 */ +.stats-card { + display: flex; + justify-content: space-around; + background: linear-gradient(135deg, #b06ab3 0%, #d4a5d6 100%); + padding: 40rpx 20rpx; + margin: 20rpx; + border-radius: 20rpx; + box-shadow: 0 4rpx 20rpx rgba(176, 106, 179, 0.3); +} + +.stat-item { + display: flex; + flex-direction: column; + align-items: center; +} + +.stat-value { + font-size: 40rpx; + font-weight: 700; + color: #fff; + margin-bottom: 8rpx; +} + +.stat-label { + font-size: 24rpx; + color: rgba(255, 255, 255, 0.8); +} + +/* 筛选标签 */ +.filter-tabs { + display: flex; + background: #fff; + margin: 0 20rpx 20rpx; + border-radius: 16rpx; + padding: 8rpx; + box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05); +} + +.tab { + flex: 1; + text-align: center; + padding: 20rpx 0; + font-size: 28rpx; + color: #666; + border-radius: 12rpx; + transition: all 0.3s; +} + +.tab.active { + background: linear-gradient(135deg, #b06ab3 0%, #d4a5d6 100%); + color: #fff; +} + +/* 订单列表 */ +.order-list { + padding: 0 20rpx; +} + +.order-item { + background: #fff; + border-radius: 20rpx; + padding: 24rpx; + margin-bottom: 20rpx; + box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05); +} + +.order-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20rpx; +} + +.user-info { + display: flex; + align-items: center; +} + +.user-avatar { + width: 80rpx; + height: 80rpx; + border-radius: 50%; + margin-right: 16rpx; +} + +.user-name { + font-size: 30rpx; + font-weight: 500; + color: #333; +} + +.order-status { + padding: 8rpx 20rpx; + border-radius: 20rpx; + font-size: 24rpx; +} + +.order-status.pending { + background: #fff3e0; + color: #ff9800; +} + +.order-status.in_progress { + background: #e3f2fd; + color: #2196f3; +} + +.order-status.completed { + background: #e8f5e9; + color: #4caf50; +} + +.order-status.cancelled { + background: #f5f5f5; + color: #9e9e9e; +} + +.order-content { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16rpx 0; + border-top: 1rpx solid #f5f5f5; + border-bottom: 1rpx solid #f5f5f5; +} + +.order-info { + display: flex; + align-items: center; + gap: 16rpx; +} + +.service-type { + background: #e8c3d4; + color: #b06ab3; + padding: 6rpx 16rpx; + border-radius: 8rpx; + font-size: 24rpx; +} + +.service-duration { + font-size: 26rpx; + color: #666; +} + +.order-price { + font-size: 36rpx; + font-weight: 600; + color: #b06ab3; +} + +.order-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 16rpx; +} + +.order-time { + font-size: 24rpx; + color: #999; +} + +.order-actions { + display: flex; + gap: 16rpx; +} + +.btn-action { + background: linear-gradient(135deg, #b06ab3 0%, #d4a5d6 100%); + color: #fff; + font-size: 24rpx; + padding: 12rpx 24rpx; + border-radius: 30rpx; + border: none; +} + +.btn-action.secondary { + background: #f5f5f5; + color: #666; +} + +/* 空状态 */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + padding: 120rpx 0; +} + +.empty-state image { + width: 240rpx; + height: 240rpx; + margin-bottom: 30rpx; + opacity: 0.5; +} + +.empty-state text { + font-size: 30rpx; + color: #999; +} + +/* 加载更多 */ +.load-more { + text-align: center; + padding: 30rpx 0; +} + +.load-more text { + font-size: 26rpx; + color: #999; +} + + +/* ========== 订单评价展示样式 ========== */ +.order-review { + background: #faf5ff; + border-radius: 16rpx; + padding: 20rpx; + margin-top: 16rpx; +} + +.review-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12rpx; +} + +.review-label { + font-size: 24rpx; + color: #6a7282; +} + +.review-rating { + display: flex; + gap: 4rpx; +} + +.review-rating .star { + font-size: 24rpx; +} + +.review-content { + font-size: 26rpx; + color: #364153; + line-height: 1.5; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.review-reply { + margin-top: 12rpx; + padding-top: 12rpx; + border-top: 1rpx solid #e5e7eb; +} + +.reply-label { + font-size: 22rpx; + color: #6a7282; +} + +.reply-text { + font-size: 24rpx; + color: #4a5565; + margin-left: 8rpx; +} + +.reply-btn { + display: inline-block; + margin-top: 12rpx; + font-size: 24rpx; + color: #b06ab3; +} + +/* ========== 回复评价弹窗样式 ========== */ +.reply-modal-mask { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; +} + +.reply-modal { + position: fixed; + left: 0; + right: 0; + bottom: 0; + background: #fff; + border-radius: 32rpx 32rpx 0 0; + z-index: 1001; + max-height: 80vh; + display: flex; + flex-direction: column; + animation: slideUp 0.3s ease; +} + +@keyframes slideUp { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} + +.reply-modal.show { + transform: translateY(0); +} + +.reply-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 32rpx; + border-bottom: 2rpx solid #f3f4f6; +} + +.reply-modal-title { + font-size: 36rpx; + font-weight: 700; + color: #101828; +} + +.reply-modal-close { + width: 48rpx; + height: 48rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.close-icon { + font-size: 48rpx; + color: #6a7282; + line-height: 1; +} + +.reply-modal-content { + flex: 1; + padding: 32rpx; + overflow-y: auto; +} + +/* 评价信息 */ +.review-info { + background: #f9fafb; + border-radius: 16rpx; + padding: 24rpx; + margin-bottom: 24rpx; +} + +.review-rating-row { + display: flex; + align-items: center; + gap: 12rpx; + margin-bottom: 12rpx; +} + +.rating-label { + font-size: 26rpx; + color: #6a7282; +} + +.rating-stars { + display: flex; + gap: 4rpx; +} + +.rating-stars .star { + font-size: 28rpx; +} + +.review-tags { + display: flex; + flex-wrap: wrap; + gap: 12rpx; + margin-bottom: 12rpx; +} + +.review-tags .tag { + background: #fff0f3; + color: #b06ab3; + padding: 6rpx 16rpx; + border-radius: 100rpx; + font-size: 22rpx; +} + +.review-text { + font-size: 28rpx; + color: #364153; + line-height: 1.6; +} + +/* 已有回复 */ +.existing-reply { + background: #ecfdf5; + border-radius: 16rpx; + padding: 24rpx; + margin-bottom: 24rpx; +} + +.existing-reply-label { + font-size: 24rpx; + color: #059669; + display: block; + margin-bottom: 8rpx; +} + +.existing-reply-text { + font-size: 28rpx; + color: #065f46; + line-height: 1.6; +} + +/* 回复输入 */ +.reply-input-section { + margin-bottom: 24rpx; +} + +.input-label { + font-size: 28rpx; + font-weight: 600; + color: #101828; + margin-bottom: 12rpx; + display: block; +} + +.reply-textarea { + width: 100%; + height: 200rpx; + padding: 24rpx; + background: #f9fafb; + border-radius: 16rpx; + font-size: 28rpx; + color: #101828; + box-sizing: border-box; +} + +.char-count { + display: block; + text-align: right; + font-size: 24rpx; + color: #99a1af; + margin-top: 8rpx; +} + +/* 提交按钮 */ +.reply-modal-footer { + padding: 24rpx 32rpx; + padding-bottom: calc(24rpx + env(safe-area-inset-bottom)); + border-top: 2rpx solid #f3f4f6; +} + +.submit-btn { + width: 100%; + height: 96rpx; + background: linear-gradient(135deg, #b06ab3 0%, #d4a5d6 100%); + color: #fff; + font-size: 32rpx; + font-weight: 600; + border-radius: 48rpx; + border: none; + display: flex; + align-items: center; + justify-content: center; +} + +.submit-btn.disabled { + opacity: 0.6; +} + +.submit-btn::after { + border: none; +} diff --git a/pages/cooperation-applications/cooperation-applications.js b/pages/cooperation-applications/cooperation-applications.js new file mode 100644 index 0000000..4730609 --- /dev/null +++ b/pages/cooperation-applications/cooperation-applications.js @@ -0,0 +1,71 @@ +// pages/cooperation-applications/cooperation-applications.js +Page({ + + /** + * 页面的初始数据 + */ + data: { + + }, + + /** + * 生命周期函数--监听页面加载 + */ + onLoad(options) { + + }, + + /** + * 生命周期函数--监听页面初次渲染完成 + */ + onReady() { + + }, + + /** + * 生命周期函数--监听页面显示 + */ + onShow() { + + }, + + /** + * 生命周期函数--监听页面隐藏 + */ + onHide() { + + }, + + /** + * 生命周期函数--监听页面卸载 + */ + onUnload() { + + }, + + /** + * 页面相关事件处理函数--监听用户下拉动作 + */ + onPullDownRefresh() { + + }, + + /** + * 页面上拉触底事件的处理函数 + */ + onReachBottom() { + + }, + + /** + * 用户点击右上角分享 + */ + onShareAppMessage() { + const referralCode = wx.getStorageSync('referralCode') || '' + const referralCodeParam = referralCode ? `?referralCode=${referralCode}` : '' + return { + title: '合作申请', + path: `/pages/cooperation-applications/cooperation-applications${referralCodeParam}` + } + }, +}) \ No newline at end of file diff --git a/pages/cooperation-applications/cooperation-applications.json b/pages/cooperation-applications/cooperation-applications.json new file mode 100644 index 0000000..8835af0 --- /dev/null +++ b/pages/cooperation-applications/cooperation-applications.json @@ -0,0 +1,3 @@ +{ + "usingComponents": {} +} \ No newline at end of file diff --git a/pages/cooperation-applications/cooperation-applications.wxml b/pages/cooperation-applications/cooperation-applications.wxml new file mode 100644 index 0000000..17506bd --- /dev/null +++ b/pages/cooperation-applications/cooperation-applications.wxml @@ -0,0 +1,2 @@ + +pages/cooperation-applications/cooperation-applications.wxml \ No newline at end of file diff --git a/pages/cooperation-applications/cooperation-applications.wxss b/pages/cooperation-applications/cooperation-applications.wxss new file mode 100644 index 0000000..1283b1d --- /dev/null +++ b/pages/cooperation-applications/cooperation-applications.wxss @@ -0,0 +1 @@ +/* pages/cooperation-applications/cooperation-applications.wxss */ \ No newline at end of file diff --git a/pages/counselor-detail/counselor-detail.js b/pages/counselor-detail/counselor-detail.js new file mode 100644 index 0000000..d50cc10 --- /dev/null +++ b/pages/counselor-detail/counselor-detail.js @@ -0,0 +1,1171 @@ +// pages/counselor-detail/counselor-detail.js - 陪聊师详情页面 +// 对接后端API + +const api = require('../../utils/api') +const app = getApp() + +Page({ + data: { + statusBarHeight: 44, + navBarHeight: 44, + totalNavHeight: 88, // 总导航栏高度(状态栏+导航内容) + menuButtonTop: 0, + menuButtonHeight: 32, + loading: true, + counselor: null, + messages: [], + currentTime: '', + inputText: '', + inputFocus: false, + // 语音录音相关 + isVoiceMode: false, + isRecording: false, + voiceCancelHint: false, + recordingDuration: 0, + recordingStartY: 0, + // 下单相关 + showOrderModal: false, + selectedDuration: 30, + selectedServiceType: 'text', // 服务类型:text/voice + durations: [ + { value: 15, label: '15分钟' }, + { value: 30, label: '30分钟' }, + { value: 60, label: '60分钟' } + ], + // 人物介绍弹窗 + showProfileModal: false, + // 评价弹窗 + showReviewModal: false, + reviews: [], + reviewStats: { + totalCount: 0, + goodRate: 100 + }, + hasMoreReviews: true, + loadingReviews: false, + reviewPage: 1, + // 更多功能面板 + showMorePanel: false, + // 表情面板 + showEmoji: false, + emojis: [ + "😊", "😀", "😁", "😃", "😂", "🤣", "😅", "😆", "😉", "😋", "😎", "😍", "😘", "🥰", "😗", "😙", + "🙂", "🤗", "🤩", "🤔", "😐", "😑", "😶", "🙄", "😏", "😣", "😥", "😮", "😯", "😪", "😫", "😴", + "🥱", "😌", "😛", "😜", "😝", "🤤", "😒", "😓", "😔", "😕", "🙃", "🤑", "😲", "☹️", "🙁", "😖", + "😞", "😟", "😤", "😢", "😭", "😦", "😧", "😨", "😩", "🤯", "😬", "😰", "😱", "🥵", "🥶", "😳", + "🤪", "😵", "🥴", "😠", "😡", "🤬", "😷", "🤒", "🤕", "🤢", "🤮", "🤧", "😇", "🥳", "🥺", "🤠", + "❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", "🤎", "💔", "❣️", "💕", "💞", "💓", "💗", "💖", + "💘", "💝", "💟", "👍", "👎", "👏", "🙌", "👐", "🤲", "🤝", "🙏", "✌️", "🤞", "🤟", "🤘", "👌" + ] + }, + + onLoad(options) { + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + + // 获取胶囊按钮位置信息 + const menuButton = wx.getMenuButtonBoundingClientRect() + + // 正确计算导航栏高度的方法: + // 导航内容高度 = 胶囊按钮高度 + 上下边距 + // 胶囊按钮距离状态栏的间距 = menuButton.top - statusBarHeight + // 导航内容高度 = 胶囊按钮高度 + 2 * 间距(上下对称) + const navContentHeight = menuButton.height + (menuButton.top - statusBarHeight) * 2 + + // 整个导航栏高度(包含状态栏)= 状态栏高度 + 导航内容高度 + const totalNavHeight = statusBarHeight + navContentHeight + + // 获取当前时间 + const now = new Date() + const hours = now.getHours() + const minutes = now.getMinutes().toString().padStart(2, '0') + const period = hours < 12 ? '上午' : (hours < 18 ? '下午' : '晚上') + const displayHour = hours > 12 ? hours - 12 : hours + + this.setData({ + statusBarHeight, + navBarHeight: navContentHeight, // 导航内容高度(不含状态栏) + totalNavHeight, // 总高度(含状态栏) + menuButtonTop: menuButton.top, + menuButtonHeight: menuButton.height, + currentTime: `${period} ${displayHour}:${minutes}` + }) + + // 加载陪聊师数据 + if (options.id) { + this.loadCounselorDetail(options.id) + } else { + wx.showToast({ title: '参数错误', icon: 'none' }) + setTimeout(() => wx.navigateBack(), 1500) + } + }, + + /** + * 页面卸载时清理资源 + */ + onUnload() { + // 停止录音 + if (this.data.isRecording && this.recorderManager) { + this.voiceCanceled = true + this.recorderManager.stop() + } + + // 清除录音计时器 + if (this.recordingTimer) { + clearInterval(this.recordingTimer) + this.recordingTimer = null + } + + // 清理录音管理器 + if (this.recorderManager) { + this.recorderManager = null + } + }, + + /** + * 页面隐藏时 + */ + onHide() { + // 停止录音 + if (this.data.isRecording && this.recorderManager) { + this.voiceCanceled = true + this.recorderManager.stop() + this.setData({ isRecording: false }) + } + }, + + /** + * 加载陪聊师详情 + */ + async loadCounselorDetail(id) { + this.setData({ loading: true }) + + try { + console.log('加载陪聊师详情,ID:', id) + const res = await api.companion.getDetail(id) + + console.log('陪聊师详情响应:', res) + + // 兼容两种返回格式:{ success: true, data: {...} } 或 { code: 0, data: {...} } + if ((res.success || res.code === 0) && res.data) { + const counselor = this.transformCounselor(res.data) + + console.log('转换后的陪聊师数据:', counselor) + + // 设置欢迎消息 + const welcomeMsg = { + id: 1, + content: `您好,我是${counselor.name}。很高兴在这里陪伴您。有什么可以帮助您的吗?【系统自动回复】` + } + + this.setData({ + counselor, + messages: [welcomeMsg], + loading: false + }) + } else { + console.error('API返回失败:', res.error || res.message) + wx.showToast({ title: res.error || '加载失败', icon: 'none' }) + this.setData({ loading: false }) + setTimeout(() => wx.navigateBack(), 1500) + } + } catch (err) { + console.error('加载陪聊师详情失败', err) + wx.showToast({ title: '网络错误', icon: 'none' }) + this.setData({ loading: false }) + setTimeout(() => wx.navigateBack(), 1500) + } + }, + + /** + * 加载模拟陪聊师数据 + */ + loadMockCounselor(id) { + const mockData = { + 'c001': { + id: 'c001', + name: '林心怡', + avatarColor: '#e8b4d8', + avatarColorEnd: '#c984cd', + location: '北京', + city: '北京', + age: '28岁', + experience: '5年咨询经验', + education: '心理学硕士', + serviceCount: 1286, + repeatCount: 423, + rating: 4.96, + quote: '每一次倾诉,都是心灵的释放', + certification: '国家二级心理咨询师 | 情感咨询专家认证', + status: 'online', + statusText: '在线', + isBusy: false, + levelCode: 'junior', + levelName: '初级', + textPrice: 0.5, + voicePrice: 1 + }, + 'c002': { + id: 'c002', + name: '张明辉', + avatarColor: '#a8d8ea', + avatarColorEnd: '#6bb3d9', + location: '上海', + city: '上海', + age: '35岁', + experience: '8年咨询经验', + education: '应用心理学博士', + serviceCount: 2156, + repeatCount: 687, + rating: 4.92, + quote: '用专业的态度,温暖每一颗心', + certification: '高级心理咨询师 | 职场心理专家', + status: 'online', + statusText: '在线', + isBusy: false, + levelCode: 'senior', + levelName: '高级', + textPrice: 1.5, + voicePrice: 3 + }, + 'c003': { + id: 'c003', + name: '王雨萱', + avatarColor: '#f8c8dc', + avatarColorEnd: '#e89bb8', + location: '深圳', + city: '深圳', + age: '26岁', + experience: '3年咨询经验', + education: '心理学学士', + serviceCount: 856, + repeatCount: 298, + rating: 4.89, + quote: '倾听你的故事,陪伴你的成长', + certification: '情感咨询师 | 青年心理辅导员', + status: 'offline', + statusText: '离线', + isBusy: true, + levelCode: 'intermediate', + levelName: '中级', + textPrice: 1, + voicePrice: 2 + }, + 'c004': { + id: 'c004', + name: '李思远', + avatarColor: '#b8d4e3', + avatarColorEnd: '#8ab4cf', + location: '广州', + city: '广州', + age: '42岁', + experience: '15年咨询经验', + education: '临床心理学硕士', + serviceCount: 3421, + repeatCount: 1156, + rating: 4.98, + quote: '专业倾听,用心陪伴每一刻', + certification: '资深心理治疗师 | 家庭治疗师认证', + status: 'online', + statusText: '在线', + isBusy: false, + levelCode: 'expert', + levelName: '资深', + textPrice: 2, + voicePrice: 4 + }, + 'c005': { + id: 'c005', + name: '陈晓琳', + avatarColor: '#d4b8e8', + avatarColorEnd: '#b088d4', + location: '杭州', + city: '杭州', + age: '31岁', + experience: '6年咨询经验', + education: '发展心理学硕士', + serviceCount: 1567, + repeatCount: 512, + rating: 4.94, + quote: '让每一次对话都充满温暖', + certification: '心理咨询师 | 情绪管理专家', + status: 'online', + statusText: '在线', + isBusy: false, + levelCode: 'senior', + levelName: '高级', + textPrice: 1.5, + voicePrice: 3 + }, + 'c006': { + id: 'c006', + name: '赵文博', + avatarColor: '#a8e6cf', + avatarColorEnd: '#7bc9a6', + location: '成都', + city: '成都', + age: '38岁', + experience: '10年咨询经验', + education: '社会心理学博士', + serviceCount: 1892, + repeatCount: 634, + rating: 4.91, + quote: '理性分析,感性陪伴', + certification: '高级心理顾问 | 企业EAP咨询师', + status: 'busy', + statusText: '忙碌中', + isBusy: true, + levelCode: 'intermediate', + levelName: '中级', + textPrice: 1, + voicePrice: 2 + } + } + + const counselor = mockData[id] || mockData['c001'] + + // 设置欢迎消息 + const welcomeMsg = { + id: 1, + content: `您好,我是${counselor.name}。很高兴在这里陪伴您。有什么可以帮助您的吗?【系统自动回复】` + } + + this.setData({ + counselor, + messages: [welcomeMsg], + loading: false + }) + }, + + /** + * 转换陪聊师数据格式 + */ + transformCounselor(data) { + const config = require('../../config/index') + console.log('原始陪聊师数据:', data) + + const statusMap = { + online: { text: '在线', isBusy: false }, + busy: { text: '忙碌中', isBusy: true }, + offline: { text: '离线', isBusy: true } + } + + const status = data.status || data.onlineStatus || data.online_status || 'offline' + const statusInfo = statusMap[status] || statusMap.offline + + // 等级名称映射 + const levelNameMap = { + junior: '初级', + intermediate: '中级', + senior: '高级', + expert: '资深' + } + + // 优先使用 displayName,然后是 name,最后是 nickname + const name = data.displayName || data.display_name || data.name || data.nickname || '未知' + + // 处理地址,只显示城市名 + let location = data.location || data.city || '' + if (location) { + // 如果地址包含多个部分(如"北京 北京市 朝阳区"),只取第一个城市名 + const parts = location.split(/[\s,,]+/).filter(p => p.trim()) + if (parts.length > 0) { + location = parts[0].replace(/[省市区县]$/, '') || parts[0] + } + } + + // 处理头像URL - 如果是相对路径,拼接完整域名 + let avatar = data.avatar || '' + if (avatar && avatar.startsWith('/')) { + // 从 API_BASE_URL 提取域名(去掉 /api 后缀) + const baseUrl = config.API_BASE_URL.replace(/\/api$/, '') + avatar = baseUrl + avatar + } + + const result = { + id: data.id, + name: name, + avatarColor: data.avatar_color || data.avatarColor || '#c984cd', + avatarColorEnd: data.avatar_color_end || data.avatarColorEnd || '#b06ab3', + avatar: avatar, + location: location, + city: location, + age: data.age_group || data.ageGroup || data.age || '', + experience: data.experience || '', + education: data.education || '', + serviceCount: data.service_count || data.serviceCount || data.totalOrders || data.total_orders || 0, + repeatCount: data.repeat_count || data.repeatCount || 0, + rating: data.rating || 5.0, + quote: data.quote || data.bio || data.introduction || '', + certification: data.certification || '', + status: status, + statusText: data.statusText || statusInfo.text, + isBusy: data.isBusy !== undefined ? data.isBusy : statusInfo.isBusy, + // 等级信息 + levelCode: data.levelCode || data.level_code || 'junior', + levelName: data.levelName || data.level_name || levelNameMap[data.levelCode || data.level_code] || '初级', + // 基于等级的价格 + textPrice: data.textPrice || data.text_price || data.pricePerMinute || data.price_per_minute || 0.5, + voicePrice: data.voicePrice || data.voice_price || 1 + } + + console.log('转换后的陪聊师数据:', result) + return result + }, + + onBack() { + wx.navigateBack() + }, + + onMore() { + wx.showActionSheet({ + itemList: ['举报', '拉黑', '分享'], + success: (res) => { + const actions = ['举报', '拉黑', '分享'] + if (res.tapIndex === 1) { + // 拉黑 + this.addToBlacklist() + } else { + wx.showToast({ title: actions[res.tapIndex], icon: 'none' }) + } + } + }) + }, + + /** + * 添加到黑名单 + */ + async addToBlacklist() { + try { + const res = await api.settings.addToBlacklist(this.data.counselor.id) + if (res.success) { + wx.showToast({ title: '已拉黑', icon: 'success' }) + } + } catch (err) { + wx.showToast({ title: '操作失败', icon: 'none' }) + } + }, + + /** + * 免费倾诉/下单 + */ + onFreeConsult() { + const { counselor } = this.data + + if (counselor.isBusy) { + wx.showToast({ title: '陪聊师当前不在线', icon: 'none' }) + return + } + + // 显示下单弹窗 + this.setData({ showOrderModal: true }) + }, + + /** + * 关闭下单弹窗 + */ + closeOrderModal() { + this.setData({ showOrderModal: false }) + }, + + /** + * 选择时长 + */ + selectDuration(e) { + const duration = e.currentTarget.dataset.duration + this.setData({ selectedDuration: duration }) + }, + + /** + * 选择服务类型 + */ + selectServiceType(e) { + const type = e.currentTarget.dataset.type + this.setData({ selectedServiceType: type }) + }, + + /** + * 计算订单价格 + */ + calculatePrice() { + const { counselor, selectedDuration, selectedServiceType } = this.data + if (!counselor) return 0 + + const unitPrice = selectedServiceType === 'voice' ? counselor.voicePrice : counselor.textPrice + return (unitPrice * selectedDuration).toFixed(2) + }, + + /** + * 确认下单 + */ + async confirmOrder() { + const { counselor, selectedDuration, selectedServiceType } = this.data + + // 检查登录 + if (app.checkNeedLogin && app.checkNeedLogin()) return + + wx.showLoading({ title: '创建订单...' }) + + try { + const res = await api.order.createCompanionOrder({ + companion_id: counselor.id, + duration: selectedDuration, + service_type: selectedServiceType, + message: '' + }) + + wx.hideLoading() + + if (res.success && res.data) { + this.setData({ showOrderModal: false }) + + // 跳转到陪聊聊天页 + wx.navigateTo({ + url: `/pages/companion-chat/companion-chat?orderId=${res.data.id}&companionId=${counselor.id}&name=${encodeURIComponent(counselor.name)}` + }) + } else { + wx.showToast({ title: res.message || '下单失败', icon: 'none' }) + } + } catch (err) { + wx.hideLoading() + console.error('下单失败', err) + wx.showToast({ title: '下单失败', icon: 'none' }) + } + }, + + onViewProfile() { + this.setData({ showProfileModal: true }) + }, + + closeProfileModal() { + this.setData({ showProfileModal: false }) + }, + + onViewReviews() { + this.setData({ + showReviewModal: true, + reviews: [], + reviewPage: 1, + hasMoreReviews: true + }) + this.loadReviews() + }, + + closeReviewModal() { + this.setData({ showReviewModal: false }) + }, + + /** + * 加载评价列表 + */ + async loadReviews() { + if (this.data.loadingReviews || !this.data.hasMoreReviews) return + + this.setData({ loadingReviews: true }) + + try { + const res = await api.companion.getReviews(this.data.counselor.id, { + page: this.data.reviewPage, + limit: 10 + }) + + if (res.success && res.data) { + const newReviews = res.data.reviews || res.data.list || [] + const formattedReviews = newReviews.map(review => ({ + ...review, + userName: this.maskPhone(review.userName || review.user_name || '匿名用户'), + createdAt: this.formatDate(review.createdAt || review.created_at), + expanded: false + })) + + // 获取统计数据(兼容多种返回格式) + const stats = res.data.stats || {} + const totalCount = stats.reviewCount || res.data.totalCount || res.data.total || formattedReviews.length + const goodRate = stats.goodRate || res.data.goodRate || 100 + const avgRating = stats.avgRating || res.data.avgRating || this.data.counselor?.rating || 5 + + this.setData({ + reviews: [...this.data.reviews, ...formattedReviews], + reviewStats: { + totalCount: totalCount, + goodRate: goodRate, + avgRating: avgRating + }, + hasMoreReviews: res.data.hasMore !== undefined ? res.data.hasMore : formattedReviews.length >= 10, + reviewPage: this.data.reviewPage + 1, + loadingReviews: false + }) + } else { + // 使用模拟数据 + this.loadMockReviews() + } + } catch (err) { + console.error('加载评价失败', err) + this.loadMockReviews() + } + }, + + /** + * 加载模拟评价数据 + */ + loadMockReviews() { + const mockReviews = [ + { + id: 1, + userName: '138****6172', + userAvatar: '', + rating: 5, + content: '老师很有耐心,倾听我的问题后给出了很中肯的建议。咨询后感觉心里轻松了很多,对未来也有了新的规划...', + tags: ['专业', '耐心', '有效果'], + reply: '谢谢您的信任,很高兴能够帮助到您。希望您能继续保持积极的心态,有任何问题随时可以来找我交流。', + likeCount: 23, + createdAt: '2024-12-15 14:32' + }, + { + id: 2, + userName: '186****3298', + userAvatar: '', + rating: 5, + content: '第一次尝试心理咨询,老师非常专业,让我感觉很放松。通过几次咨询,我对自己的情绪有了更好的认识...', + tags: ['专业', '温暖', '有帮助'], + reply: '能够陪伴您成长是我的荣幸,继续加油!', + likeCount: 15, + createdAt: '2024-12-10 09:15' + }, + { + id: 3, + userName: '159****7721', + userAvatar: '', + rating: 5, + content: '老师的声音很温柔,聊天的过程中感觉很舒服。虽然问题还在,但是心态好了很多,会继续找老师咨询的...', + tags: ['温柔', '善于倾听'], + reply: '', + likeCount: 8, + createdAt: '2024-12-05 20:48' + }, + { + id: 4, + userName: '177****4532', + userAvatar: '', + rating: 5, + content: '咨询师很专业,能够快速理解我的问题并给出建议。性价比很高,会推荐给朋友...', + tags: ['专业', '高效'], + reply: '', + likeCount: 12, + createdAt: '2024-11-28 16:22' + }, + { + id: 5, + userName: '133****8965', + userAvatar: '', + rating: 5, + content: '非常好的一次体验,老师很有同理心,让我感受到了被理解和支持...', + tags: ['有同理心', '支持'], + reply: '感谢您的认可,祝您生活愉快!', + likeCount: 6, + createdAt: '2024-11-20 11:05' + } + ] + + this.setData({ + reviews: mockReviews, + reviewStats: { + totalCount: this.data.counselor?.serviceCount || 2952, + goodRate: 100 + }, + hasMoreReviews: false, + loadingReviews: false + }) + }, + + /** + * 加载更多评价 + */ + loadMoreReviews() { + this.loadReviews() + }, + + /** + * 展开评价内容 + */ + expandReview(e) { + const index = e.currentTarget.dataset.index + const reviews = this.data.reviews + reviews[index].expanded = true + this.setData({ reviews }) + }, + + /** + * 点赞评价 + */ + async likeReview(e) { + const reviewId = e.currentTarget.dataset.id + const reviews = this.data.reviews + const index = reviews.findIndex(r => r.id === reviewId) + + if (index !== -1) { + // 检查是否已点赞 + if (reviews[index].liked) { + wx.showToast({ title: '已点赞过了', icon: 'none' }) + return + } + + // 乐观更新UI + reviews[index].likeCount = (reviews[index].likeCount || 0) + 1 + reviews[index].liked = true + this.setData({ reviews }) + + // 调用API + try { + const res = await api.companion.likeReview(reviewId) + if (!res.success) { + // 失败时回滚 + reviews[index].likeCount -= 1 + reviews[index].liked = false + this.setData({ reviews }) + wx.showToast({ title: '点赞失败', icon: 'none' }) + } + } catch (err) { + // 失败时回滚 + reviews[index].likeCount -= 1 + reviews[index].liked = false + this.setData({ reviews }) + console.log('点赞API调用失败', err) + wx.showToast({ title: '点赞失败', icon: 'none' }) + } + } + }, + + /** + * 手机号脱敏 + */ + maskPhone(phone) { + if (!phone || phone.length < 7) return phone + if (phone.includes('****')) return phone + return phone.substring(0, 3) + '****' + phone.substring(phone.length - 4) + }, + + /** + * 格式化日期 + */ + formatDate(dateStr) { + if (!dateStr) return '' + const date = new Date(dateStr) + 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}` + }, + + onRemind() { + wx.showToast({ title: '已设置提醒', icon: 'success' }) + }, + + onVoiceMode() { + const isVoiceMode = !this.data.isVoiceMode + this.setData({ + isVoiceMode, + inputFocus: !isVoiceMode + }) + }, + + /** + * 语音按钮触摸开始 - 开始录音 + */ + onVoiceTouchStart(e) { + // 记录起始Y坐标 + const startY = e.touches[0].clientY + this.setData({ + recordingStartY: startY, + voiceCancelHint: false, + recordingDuration: 0 + }) + + // 检查录音权限 + wx.authorize({ + scope: 'scope.record', + success: () => { + // 开始录音 + this.startVoiceRecord() + }, + fail: () => { + wx.showModal({ + title: '需要录音权限', + content: '请在设置中开启录音权限', + confirmText: '去设置', + success: (res) => { + if (res.confirm) { + wx.openSetting() + } + } + }) + } + }) + }, + + /** + * 语音按钮触摸移动 - 检测上划取消 + */ + onVoiceTouchMove(e) { + if (!this.data.isRecording) return + + const currentY = e.touches[0].clientY + const startY = this.data.recordingStartY + const moveDistance = startY - currentY + + // 上划超过80px显示取消提示 + const shouldCancel = moveDistance > 80 + + if (shouldCancel !== this.data.voiceCancelHint) { + this.setData({ voiceCancelHint: shouldCancel }) + + // 震动反馈 + if (shouldCancel) { + wx.vibrateShort({ type: 'light' }) + } + } + }, + + /** + * 语音按钮触摸结束 - 停止录音并发送/取消 + */ + onVoiceTouchEnd() { + if (!this.data.isRecording) return + + const { voiceCancelHint } = this.data + + // 标记是否取消 + this.voiceCanceled = voiceCancelHint + + // 停止录音 + if (this.recorderManager) { + this.recorderManager.stop() + } + + // 清除录音计时器 + if (this.recordingTimer) { + clearInterval(this.recordingTimer) + this.recordingTimer = null + } + + this.setData({ + isRecording: false, + voiceCancelHint: false, + recordingDuration: 0 + }) + }, + + /** + * 语音按钮触摸取消 + */ + onVoiceTouchCancel() { + this.voiceCanceled = true + this.onVoiceTouchEnd() + }, + + /** + * 开始语音录音 + */ + startVoiceRecord() { + this.setData({ + isRecording: true, + voiceCancelHint: false, + recordingDuration: 0 + }) + + // 初始化录音管理器 + const recorderManager = wx.getRecorderManager() + this.recorderManager = recorderManager + + // 监听录音结束 + recorderManager.onStop((res) => { + // 清除计时器 + if (this.recordingTimer) { + clearInterval(this.recordingTimer) + this.recordingTimer = null + } + + this.setData({ isRecording: false }) + + // 如果是取消的,不发送 + if (this.voiceCanceled) { + this.voiceCanceled = false + wx.showToast({ title: '已取消', icon: 'none' }) + return + } + + // 录音时间太短 + if (res.duration < 1000) { + wx.showToast({ title: '录音时间太短', icon: 'none' }) + return + } + + // 发送语音消息 + this.sendVoiceMessage(res.tempFilePath, Math.ceil(res.duration / 1000)) + }) + + recorderManager.onError((err) => { + console.error('录音失败', err) + + // 清除计时器 + if (this.recordingTimer) { + clearInterval(this.recordingTimer) + this.recordingTimer = null + } + + this.setData({ + isRecording: false, + voiceCancelHint: false, + recordingDuration: 0 + }) + + // 模拟器不支持录音,给出友好提示 + if (err.errMsg && err.errMsg.includes('NotFoundError')) { + wx.showToast({ title: '请在真机上测试录音', icon: 'none' }) + } else { + wx.showToast({ title: '录音失败', icon: 'none' }) + } + }) + + // 开始录音 + recorderManager.start({ + duration: 60000, + format: 'mp3', + sampleRate: 16000, + numberOfChannels: 1 + }) + + // 录音计时器 + this.recordingTimer = setInterval(() => { + const duration = this.data.recordingDuration + 1 + this.setData({ recordingDuration: duration }) + + // 最长60秒自动停止 + if (duration >= 60) { + this.onVoiceTouchEnd() + } + }, 1000) + }, + + /** + * 发送语音消息 + */ + async sendVoiceMessage(filePath, duration) { + const { counselor, messages } = this.data + + // 添加语音消息到列表 + const voiceMessage = { + id: Date.now(), + content: `[语音消息 ${duration}″]`, + isUser: true, + type: 'voice', + audioUrl: filePath, + duration: duration + } + + this.setData({ + messages: [...messages, voiceMessage] + }) + + wx.showToast({ title: '语音已发送', icon: 'success' }) + + // 如果陪聊师在线,提示开始对话 + if (!counselor.isBusy) { + setTimeout(() => { + wx.showModal({ + title: '提示', + content: '是否开始与陪聊师对话?', + confirmText: '开始对话', + cancelText: '继续留言', + success: (res) => { + if (res.confirm) { + this.onFreeConsult() + } + } + }) + }, 500) + } + + // TODO: 上传语音文件到服务器 + }, + + onInput(e) { + this.setData({ inputText: e.detail.value }) + }, + + /** + * 发送消息 + */ + onSendMessage() { + const { inputText, messages, counselor } = this.data + if (!inputText.trim()) return + + // 添加用户消息 + const userMsg = { + id: Date.now(), + content: inputText, + isUser: true + } + + this.setData({ + messages: [...messages, userMsg], + inputText: '' + }) + + // 如果陪聊师在线,可以跳转到聊天页面 + if (!counselor.isBusy) { + wx.showModal({ + title: '提示', + content: '是否开始与陪聊师对话?', + confirmText: '开始对话', + cancelText: '继续留言', + success: (res) => { + if (res.confirm) { + this.onFreeConsult() + } + } + }) + } + }, + + onEmoji() { + // 切换表情面板 + this.setData({ + showEmoji: !this.data.showEmoji, + showMorePanel: false, + isVoiceMode: false + }) + }, + + /** + * 选择表情 + */ + onEmojiSelect(e) { + const emoji = e.currentTarget.dataset.emoji + this.setData({ + inputText: this.data.inputText + emoji + }) + }, + + /** + * 删除表情/文字 + */ + onEmojiDelete() { + const text = this.data.inputText + if (text.length > 0) { + // 处理emoji字符(可能占用多个字符位置) + const arr = Array.from(text) + arr.pop() + this.setData({ + inputText: arr.join('') + }) + } + }, + + onAdd() { + // 切换更多功能面板显示状态 + this.setData({ + showMorePanel: !this.data.showMorePanel, + showEmoji: false, + isVoiceMode: false + }) + }, + + // 关闭更多功能面板和表情面板 + closeMorePanel() { + this.setData({ + showMorePanel: false, + showEmoji: false + }) + }, + + // 拍照 + onTakePhoto() { + this.setData({ showMorePanel: false }) + wx.chooseMedia({ + count: 1, + mediaType: ['image'], + sourceType: ['camera'], + camera: 'back', + success: (res) => { + const tempFilePath = res.tempFiles[0].tempFilePath + wx.showToast({ title: '照片已选择', icon: 'success' }) + // TODO: 发送图片消息 + }, + fail: (err) => { + if (err.errMsg !== 'chooseMedia:fail cancel') { + wx.showToast({ title: '拍照失败', icon: 'none' }) + } + } + }) + }, + + // 从相册选择图片 + onChooseImage() { + this.setData({ showMorePanel: false }) + wx.chooseMedia({ + count: 9, + mediaType: ['image'], + sourceType: ['album'], + success: (res) => { + wx.showToast({ title: `已选择${res.tempFiles.length}张图片`, icon: 'success' }) + // TODO: 发送图片消息 + }, + fail: (err) => { + if (err.errMsg !== 'chooseMedia:fail cancel') { + wx.showToast({ title: '选择图片失败', icon: 'none' }) + } + } + }) + }, + + // 发送礼物 + onSendGift() { + this.setData({ showMorePanel: false }) + wx.showToast({ title: '礼物功能开发中', icon: 'none' }) + }, + + // 语音通话 + onVoiceCall() { + this.setData({ showMorePanel: false }) + wx.showToast({ title: '语音通话功能开发中', icon: 'none' }) + }, + + // 常用语 + onQuickReply() { + this.setData({ showMorePanel: false }) + const quickReplies = [ + '你好,很高兴认识你~', + '最近怎么样?', + '有什么想聊的吗?', + '今天心情如何?', + '晚安,好梦~' + ] + wx.showActionSheet({ + itemList: quickReplies, + success: (res) => { + this.setData({ inputText: quickReplies[res.tapIndex] }) + } + }) + }, + + // 约时间 + onScheduleTime() { + this.setData({ showMorePanel: false }) + wx.showToast({ title: '约时间功能开发中', icon: 'none' }) + }, + + // 抢红包 + onRedPacket() { + this.setData({ showMorePanel: false }) + wx.showToast({ title: '红包功能开发中', icon: 'none' }) + }, + + // 测结果 + onTestResult() { + this.setData({ showMorePanel: false }) + wx.showToast({ title: '测结果功能开发中', icon: 'none' }) + } +}) diff --git a/pages/counselor-detail/counselor-detail.json b/pages/counselor-detail/counselor-detail.json new file mode 100644 index 0000000..004b603 --- /dev/null +++ b/pages/counselor-detail/counselor-detail.json @@ -0,0 +1,5 @@ +{ + "usingComponents": {}, + "navigationBarTitleText": "", + "navigationStyle": "custom" +} diff --git a/pages/counselor-detail/counselor-detail.wxml b/pages/counselor-detail/counselor-detail.wxml new file mode 100644 index 0000000..6fd199c --- /dev/null +++ b/pages/counselor-detail/counselor-detail.wxml @@ -0,0 +1,525 @@ + + + + + + + + + + {{counselor.name}} + + {{counselor.statusText}} + + + + + + + + + + + + + + + + {{counselor.name[0]}} + + + + + + {{counselor.name}} + + + {{counselor.levelName}} + + + + + {{counselor.location}} + + + {{counselor.city}} | {{counselor.age}} | {{counselor.experience}} + {{counselor.education}} + + + + + + {{counselor.serviceCount}} + 服务人次 + + + {{counselor.repeatCount}} + 回头客 + + + {{counselor.rating}} + 评分 + + + + + 聆听寄语 + {{counselor.quote}} + + + 专业资质 + {{counselor.certification}} + + + + + + + {{currentTime}} + + + + + + + + + + {{counselor.name[0]}} + + + + {{item.content}} + + + + + + + 咨询师正在服务中,无法及时回复,您可留言 + + 【空闲时提醒我】 + + + + + + + + + + + + + + 免费倾诉 + + + 📄 + 人物介绍 + + + 💬 + 查看评价 + + + + + + + + + + + + + + + + + + + {{isRecording ? (voiceCancelHint ? '松开取消' : '松开发送') : '按住 说话'}} + + + + + + + + + + + + + + + + + + + {{item}} + + + + + + + + + 发送 + + + + + + + + + + + + + + + 拍照 + + + + + + + + + + + 相册 + + + + + + + 礼物 + + + + + + + 语音 + + + + + + + + + + + + + 常用语 + + + + + + + 约时间 + + + + + + + + + + + 抢红包 + + + + + + NEW + + 测结果 + + + + + + + + + + + + + + + + + {{voiceCancelHint ? '松开手指,取消发送' : '手指上划,取消发送'}} + {{recordingDuration}}″ + + + + + + + + + + + + + + 选择服务 + × + + + + + + + + 文字聊天 + ¥{{counselor.textPrice}}/分钟 + + + 语音聊天 + ¥{{counselor.voicePrice}}/分钟 + + + + + + + + + + {{item.label}} + + + + + + + 预计费用 + ¥{{(selectedServiceType === 'voice' ? counselor.voicePrice : counselor.textPrice) * selectedDuration}} + + + + + + + + + + + 人物介绍 + + × + + + + + + + + + + + {{counselor.name[0]}} + + + + {{counselor.name}} + + + 性别: + {{counselor.gender || '女'}} + + + 年龄: + {{counselor.age || '90后'}} + + + 地区: + {{counselor.city || counselor.location || '未知'}} + + + 学历: + {{counselor.education || '本科'}} + + + + + + + + + + {{counselor.serviceCount || 0}} + 服务人次 + + + + {{counselor.repeatCount || 0}} + 回头客 + + + + {{counselor.rating || 5.0}} + 评分 + + + + + + + 专业资质 + {{counselor.certification || '心理咨询师'}} + + + + + 擅长领域 + + + {{item}} + + + + + + + 个人介绍 + {{counselor.introduction || counselor.experience || '90后 本科 心理咨询师1年系统训练'}} + + + + + 聆听寄语 + "{{counselor.quote || '生活中的每一份情绪都值得被温柔对待,我愿意陪伴您度过人生的每一个重要时刻。'}}" + + + + + + + + + + 全部评价 + + × + + + + + + + + + {{counselor.name[0]}} + + + + + {{counselor.rating || 4.9}} + + + + + + {{reviewStats.totalCount || 0}}条评价 + + + + {{reviewStats.goodRate || 100}}% + 好评率 + + + + + + + + + + + + {{item.userName[0] || '用'}} + + + + + + + + {{item.content}} + 展开 + + + + + + {{tag}} + + + + + + + 咨询师回复: + {{item.reply}} + + + + + + + + + + + + 已经到底了~ + + + + + 加载中... + + + diff --git a/pages/counselor-detail/counselor-detail.wxss b/pages/counselor-detail/counselor-detail.wxss new file mode 100644 index 0000000..5c717f5 --- /dev/null +++ b/pages/counselor-detail/counselor-detail.wxss @@ -0,0 +1,1738 @@ +/* 陪聊师详情页面样式 */ +page { + background: #ededed; + height: 100%; +} + +.page-container { + height: 100vh; + display: flex; + flex-direction: column; + position: relative; +} + +.status-bar { + background: #fff; +} + +/* 顶部导航栏 - 固定定位 */ +.nav-bar { + position: fixed; + top: 0; + left: 0; + right: 0; + background: #fff; + border-bottom: 2rpx solid #f3f4f6; + z-index: 100; + box-sizing: border-box; +} + +.nav-content { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 32rpx; + height: 100%; +} + +.nav-back { + width: 64rpx; + height: 64rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.nav-placeholder { + width: 64rpx; + height: 64rpx; +} + +.back-icon { + width: 48rpx; + height: 48rpx; +} + +.nav-title-wrap { + display: flex; + align-items: center; + gap: 16rpx; +} + +.nav-title { + font-size: 32rpx; + color: #101828; +} + +.status-badge { + padding: 4rpx 16rpx; + border-radius: 8rpx; + background: #ff8904; +} + +.status-badge.online { + background: #05df72; +} + +.status-badge.busy { + background: #ff8904; +} + +.status-text { + font-size: 24rpx; + color: #fff; +} + +/* 内容区域 */ +.content-scroll { + flex: 1; + padding: 16rpx 32rpx; + padding-bottom: 450rpx; /* 为底部固定区域留出空间 */ + box-sizing: border-box; +} + +/* 用户信息卡片 */ +.profile-card { + background: #fff; + border-radius: 32rpx; + overflow: hidden; + box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.1), 0 2rpx 4rpx rgba(0, 0, 0, 0.1); +} + +.profile-header { + display: flex; + gap: 24rpx; + padding: 32rpx; + background: linear-gradient(152deg, #fce7f3 0%, #f3e8ff 50%, #e9d4ff 100%); +} + +.profile-avatar-wrap { + width: 128rpx; + height: 160rpx; +} + +.profile-avatar-placeholder { + width: 128rpx; + height: 160rpx; + border-radius: 20rpx; + display: flex; + align-items: center; + justify-content: center; +} + +/* 真实头像图片样式 */ +.profile-avatar-image { + width: 128rpx; + height: 160rpx; + border-radius: 20rpx; +} + +.profile-avatar-text { + font-size: 48rpx; + font-weight: 700; + color: #fff; +} + +.profile-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 8rpx; +} + +.profile-name-row { + display: flex; + align-items: center; + justify-content: space-between; +} + +.name-level-wrap { + display: flex; + align-items: center; + gap: 12rpx; +} + +.profile-name { + font-size: 36rpx; + font-weight: 700; + color: #101828; +} + +/* 名字后面的等级标签 */ +.name-level-badge { + padding: 4rpx 16rpx; + border-radius: 16rpx; + font-size: 20rpx; + white-space: nowrap; +} + +.name-level-badge.level-junior { + background: linear-gradient(135deg, #a8d8ea 0%, #6bb3d9 100%); +} + +.name-level-badge.level-intermediate { + background: linear-gradient(135deg, #b8e986 0%, #7bc96f 100%); +} + +.name-level-badge.level-senior { + background: linear-gradient(135deg, #ffd700 0%, #ffb347 100%); +} + +.name-level-badge.level-expert { + background: linear-gradient(135deg, #e8b4d8 0%, #c984cd 100%); +} + +.name-level-text { + color: #fff; + font-weight: 600; + font-size: 22rpx; +} + +.profile-location { + display: flex; + align-items: center; + gap: 8rpx; +} + +.location-icon { + width: 24rpx; + height: 24rpx; +} + +.location-text { + font-size: 24rpx; + color: #6a7282; +} + +.profile-desc { + font-size: 28rpx; + color: #4a5565; +} + +.profile-education { + font-size: 28rpx; + color: #4a5565; +} + +.profile-stats { + display: flex; + justify-content: space-around; + padding: 24rpx 32rpx; + background: linear-gradient(152deg, #fce7f3 0%, #f3e8ff 50%, #e9d4ff 100%); + border-top: 2rpx solid rgba(255, 255, 255, 0.3); +} + +.stat-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 4rpx; +} + +.stat-value { + font-size: 40rpx; + font-weight: 700; + color: #101828; +} + +.stat-label { + font-size: 24rpx; + color: #6a7282; +} + +.profile-details { + padding: 24rpx 32rpx; + display: flex; + flex-direction: column; + gap: 16rpx; +} + +.detail-row { + display: flex; + gap: 16rpx; +} + +.detail-label { + font-size: 32rpx; + color: #364153; + width: 128rpx; + flex-shrink: 0; +} + +.detail-value { + font-size: 32rpx; + color: #4a5565; + flex: 1; +} + +/* 时间戳 */ +.time-stamp { + text-align: center; + padding: 32rpx 0; +} + +.time-text { + font-size: 24rpx; + color: #99a1af; +} + +/* 聊天消息 */ +.message-list { + display: flex; + flex-direction: column; + gap: 24rpx; +} + +.message-item { + display: flex; + gap: 16rpx; +} + +.message-avatar { + width: 80rpx; + height: 80rpx; + flex-shrink: 0; +} + +.avatar-placeholder { + width: 80rpx; + height: 80rpx; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +/* 消息头像图片样式 */ +.avatar-image-small { + width: 80rpx; + height: 80rpx; + border-radius: 50%; +} + +.avatar-text { + font-size: 32rpx; + font-weight: 700; + color: #fff; +} + +.message-bubble { + background: #fff; + border-radius: 0 20rpx 20rpx 20rpx; + padding: 24rpx; + box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.1), 0 2rpx 4rpx rgba(0, 0, 0, 0.1); + max-width: 80%; +} + +.message-text { + font-size: 32rpx; + color: #1e2939; + line-height: 1.6; +} + +/* 服务中提示 */ +.busy-notice { + text-align: center; + padding: 32rpx 0; +} + +.busy-text { + font-size: 28rpx; + color: #6a7282; +} + +.remind-btn { + margin-top: 16rpx; +} + +.remind-text { + font-size: 32rpx; + color: #2b7fff; +} + +/* 底部操作按钮 */ +.action-buttons { + position: fixed; + bottom: calc(140rpx + env(safe-area-inset-bottom)); + left: 0; + right: 0; + display: flex; + gap: 16rpx; + padding: 0 32rpx 24rpx; + background: transparent; + z-index: 97; +} + +.action-btn { + flex: 1; + height: 100rpx; + border-radius: 100rpx; + display: flex; + align-items: center; + justify-content: center; + gap: 16rpx; + box-shadow: 0 8rpx 20rpx -6rpx rgba(0, 0, 0, 0.1), 0 20rpx 50rpx -10rpx rgba(0, 0, 0, 0.1); +} + +.action-btn.primary { + background: linear-gradient(90deg, #05df72 0%, #00bc7d 100%); +} + +.action-btn.secondary { + background: #fff; + border: 2rpx solid #e5e7eb; + box-shadow: 0 2rpx 4rpx -2rpx rgba(0, 0, 0, 0.1), 0 2rpx 6rpx 0 rgba(0, 0, 0, 0.1); +} + +.action-icon { + width: 40rpx; + height: 40rpx; +} + +.action-icon.white { + filter: brightness(0) invert(1); +} + +.action-emoji { + font-size: 32rpx; +} + +.action-text { + font-size: 32rpx; + color: #364153; + text-align: center; +} + +.action-text.white { + color: #fff; +} + +/* 电话图标样式 */ +.phone-icon-wrap { + width: 40rpx; + height: 40rpx; + position: relative; +} + +.phone-icon-svg { + width: 40rpx; + height: 40rpx; +} + +/* 面板打开时的透明遮罩层 - 点击关闭面板 */ +.panel-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: transparent; + z-index: 98; +} + +/* 底部输入区域容器 */ +.bottom-input-container { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: #fff; + z-index: 100; +} + +/* 面板打开时移除底部安全区域 */ +.bottom-input-container.panel-open .input-bar { + padding-bottom: 26rpx; +} + +/* 底部输入框 */ +.input-bar { + display: flex; + align-items: center; + gap: 24rpx; + padding: 26rpx 32rpx; + padding-bottom: calc(26rpx + env(safe-area-inset-bottom)); + background: #fff; + border-top: 2rpx solid #e5e7eb; +} + +.voice-btn { + width: 56rpx; + height: 56rpx; + border: 2rpx solid #99a1af; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.voice-icon-wrap { + display: flex; + align-items: center; + justify-content: center; +} + +.voice-bar { + width: 16rpx; + height: 24rpx; + background: #4a5565; + border-radius: 12rpx; +} + +.input-wrap { + flex: 1; + height: 88rpx; + background: #f3f4f6; + border-radius: 100rpx; + padding: 0 32rpx; + display: flex; + align-items: center; +} + +.message-input { + width: 100%; + font-size: 28rpx; + color: #101828; +} + +.message-input::placeholder { + color: #99a1af; +} + +.emoji-btn { + width: 56rpx; + height: 56rpx; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.emoji-icon { + width: 48rpx; + height: 48rpx; +} + +.add-btn { + width: 56rpx; + height: 56rpx; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +/* 加号图标 */ +.plus-icon { + width: 48rpx; + height: 48rpx; + position: relative; +} + +.plus-h { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 32rpx; + height: 4rpx; + background: #4a5565; + border-radius: 2rpx; +} + +.plus-v { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 4rpx; + height: 32rpx; + background: #4a5565; + border-radius: 2rpx; +} + +/* 底部安全区域 - 不再需要,已通过fixed定位处理 */ +.safe-area { + display: none; +} + + +/* 下单弹窗样式 */ +.order-modal-mask { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; +} + +.order-modal { + position: fixed; + left: 0; + right: 0; + bottom: 0; + background: #fff; + border-radius: 32rpx 32rpx 0 0; + padding: 32rpx; + z-index: 1001; + animation: slideUp 0.3s ease; +} + +@keyframes slideUp { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 32rpx; +} + +.modal-title { + font-size: 36rpx; + font-weight: 700; + color: #101828; +} + +.modal-close { + font-size: 48rpx; + color: #99a1af; + line-height: 1; +} + +.section-label { + font-size: 28rpx; + color: #6a7282; + margin-bottom: 16rpx; + display: block; +} + +/* 服务类型选择 */ +.service-type-section { + margin-bottom: 32rpx; +} + +.service-type-options { + display: flex; + gap: 24rpx; +} + +.service-type-item { + flex: 1; + padding: 24rpx; + border: 2rpx solid #e5e7eb; + border-radius: 16rpx; + text-align: center; + transition: all 0.3s; +} + +.service-type-item.active { + border-color: #e91e63; + background: #fce7f3; +} + +.type-name { + display: block; + font-size: 28rpx; + color: #101828; + margin-bottom: 8rpx; +} + +.type-price { + font-size: 24rpx; + color: #e91e63; + font-weight: 600; +} + +/* 时长选择 */ +.duration-section { + margin-bottom: 32rpx; +} + +.duration-options { + display: flex; + gap: 24rpx; +} + +.duration-item { + flex: 1; + padding: 20rpx; + border: 2rpx solid #e5e7eb; + border-radius: 16rpx; + text-align: center; + transition: all 0.3s; +} + +.duration-item.active { + border-color: #e91e63; + background: #fce7f3; +} + +.duration-text { + font-size: 28rpx; + color: #101828; +} + +/* 价格汇总 */ +.price-summary { + display: flex; + justify-content: space-between; + align-items: center; + padding: 24rpx; + background: #f9fafb; + border-radius: 16rpx; + margin-bottom: 32rpx; +} + +.summary-label { + font-size: 28rpx; + color: #6a7282; +} + +.summary-price { + font-size: 40rpx; + font-weight: 700; + color: #e91e63; +} + +/* 确认按钮 */ +.confirm-btn { + width: 100%; + height: 96rpx; + background: linear-gradient(to right, #e91e63, #c2185b); + color: #fff; + font-size: 32rpx; + font-weight: 600; + border-radius: 48rpx; + border: none; +} + +.confirm-btn::after { + border: none; +} + + +/* ========== 人物介绍弹窗样式 - Figma设计 ========== */ +.profile-modal-mask { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; +} + +.profile-modal { + position: fixed; + left: 0; + right: 0; + bottom: 0; + background: #fff; + border-radius: 64rpx 64rpx 0 0; + z-index: 1001; + max-height: 90vh; + display: flex; + flex-direction: column; + animation: slideUp 0.3s ease; + box-shadow: 0 -50rpx 100rpx -24rpx rgba(0, 0, 0, 0.25); +} + +.profile-modal.show { + transform: translateY(0); +} + +/* 弹窗头部 - 固定在顶部 */ +.profile-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 48rpx; + height: 154rpx; + background: rgba(255, 255, 255, 0.95); + border-bottom: 2rpx solid #f3f4f6; + border-radius: 64rpx 64rpx 0 0; + flex-shrink: 0; +} + +.profile-modal-title { + font-size: 40rpx; + font-weight: 900; + color: #101828; + line-height: 1.4; +} + +.profile-modal-close { + width: 72rpx; + height: 72rpx; + background: #f3f4f6; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.close-icon { + font-size: 40rpx; + color: #4a5565; + line-height: 1; +} + +/* 弹窗内容区 */ +.profile-modal-content { + flex: 1; + padding: 40rpx 48rpx; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 40rpx; +} + +/* 底部指示条 */ +.profile-modal-content::after { + content: ''; + display: block; + width: 224rpx; + height: 12rpx; + background: #d1d5dc; + border-radius: 100rpx; + margin: 40rpx auto 20rpx; + flex-shrink: 0; +} + +/* 基本信息区 - 卡片样式 */ +.profile-info-section { + display: flex; + gap: 40rpx; + padding: 32rpx; + background: #fff; + border: 2rpx solid #f3f4f6; + border-radius: 32rpx; +} + +.profile-avatar-large { + width: 180rpx; + height: 180rpx; + flex-shrink: 0; +} + +.avatar-placeholder-large { + width: 180rpx; + height: 180rpx; + border-radius: 32rpx; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 8rpx 12rpx -8rpx rgba(0, 0, 0, 0.1), 0 20rpx 30rpx -6rpx rgba(0, 0, 0, 0.1); +} + +.avatar-text-large { + font-size: 64rpx; + font-weight: 700; + color: #fff; +} + +.avatar-img-large { + width: 100%; + height: 100%; + border-radius: 32rpx; +} + +.profile-basic-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 24rpx; + padding-top: 8rpx; +} + +.profile-name-large { + font-size: 48rpx; + font-weight: 900; + color: #101828; + line-height: 1.33; +} + +.profile-details-list { + display: flex; + flex-direction: column; + gap: 16rpx; +} + +.profile-detail-item { + font-size: 32rpx; + color: #364153; + line-height: 1.5; + display: flex; +} + +.profile-detail-item text:first-child, +.detail-label-text { + color: #6a7282; + font-weight: 700; + width: 120rpx; + flex-shrink: 0; +} + +.detail-value-text { + color: #364153; + font-weight: 400; +} + +/* 统计数据卡片 - 渐变背景 */ +.profile-stats-card { + background: linear-gradient(180deg, #faf8fb 0%, #f9f5fa 100%); + border: 2rpx solid rgba(232, 213, 240, 0.3); + border-radius: 32rpx; + padding: 42rpx; +} + +.stats-row { + display: flex; + justify-content: space-around; + align-items: center; +} + +.stats-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 12rpx; + flex: 1; +} + +.stats-value { + font-size: 60rpx; + font-weight: 900; + color: #101828; + line-height: 1.2; +} + +.stats-label { + font-size: 28rpx; + color: #4a5565; + line-height: 1.43; +} + +.stats-divider { + width: 2rpx; + height: 124rpx; + background: rgba(229, 231, 235, 0.6); +} + +/* 信息区块卡片 - 通用样式 */ +.profile-section-card { + background: #fff; + border: 2rpx solid #f3f4f6; + border-radius: 32rpx; + padding: 34rpx; +} + +/* 区块标题带装饰点 */ +.section-title { + font-size: 36rpx; + font-weight: 900; + color: #101828; + margin-bottom: 20rpx; + display: flex; + align-items: center; + gap: 28rpx; + line-height: 1.56; +} + +.section-title::before { + content: ''; + width: 12rpx; + height: 12rpx; + background: #b06ab3; + border-radius: 50%; + flex-shrink: 0; +} + +.section-content { + font-size: 32rpx; + color: #364153; + line-height: 1.625; + padding-left: 40rpx; +} + +/* 擅长领域标签 - 渐变背景 */ +.expertise-tags { + display: flex; + flex-wrap: wrap; + gap: 20rpx; + padding-left: 40rpx; +} + +.expertise-tag { + background: linear-gradient(180deg, #e8d5f0 0%, #f0e5f5 100%); + border: 2rpx solid rgba(216, 180, 230, 0.3); + border-radius: 100rpx; + padding: 14rpx 34rpx; +} + +.tag-text { + font-size: 32rpx; + font-weight: 700; + color: #8b4e9e; +} + +/* 聆听寄语 - 特殊渐变背景 */ +.profile-section-card.quote-card { + background: linear-gradient(180deg, #f9f5fa 0%, #faf8fb 100%); + border: 2rpx solid rgba(232, 213, 240, 0.5); + padding: 42rpx; +} + +.quote-card .section-content { + font-size: 32rpx; + color: #364153; + line-height: 1.8; + padding-left: 40rpx; +} + +/* ========== 评价弹窗样式 ========== */ +.review-modal-mask { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; +} + +.review-modal { + position: fixed; + left: 0; + right: 0; + bottom: 0; + background: #f9fafb; + border-radius: 24rpx 24rpx 0 0; + z-index: 1001; + max-height: 85vh; + display: flex; + flex-direction: column; + animation: slideUp 0.3s ease; +} + +.review-modal.show { + transform: translateY(0); +} + +.review-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 32rpx; + background: #fff; + border-bottom: 2rpx solid #f3f4f6; + border-radius: 24rpx 24rpx 0 0; +} + +.review-modal-title { + font-size: 36rpx; + font-weight: 700; + color: #101828; +} + +.review-modal-close { + width: 48rpx; + height: 48rpx; + display: flex; + align-items: center; + justify-content: center; +} + +/* 评分汇总 */ +.review-summary { + display: flex; + justify-content: space-between; + align-items: center; + padding: 32rpx; + background: #fff; + border-bottom: 2rpx solid #f3f4f6; +} + +.summary-left { + display: flex; + align-items: center; + gap: 32rpx; +} + +.summary-avatar { + width: 128rpx; + height: 128rpx; +} + +.avatar-placeholder-small { + width: 128rpx; + height: 128rpx; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.avatar-text-small { + font-size: 48rpx; + font-weight: 700; + color: #fff; +} + +.summary-score { + display: flex; + flex-direction: column; + gap: 8rpx; +} + +.score-row { + display: flex; + align-items: baseline; + gap: 4rpx; +} + +.score-value { + font-size: 60rpx; + font-weight: 700; + color: #101828; +} + +.score-unit { + font-size: 28rpx; + color: #6a7282; +} + +.stars-row { + display: flex; + gap: 8rpx; +} + +.star-icon { + font-size: 28rpx; +} + +.review-count { + font-size: 24rpx; + color: #6a7282; +} + +.summary-right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4rpx; +} + +.good-rate { + font-size: 48rpx; + font-weight: 700; + color: #ff6900; +} + +.good-rate-label { + font-size: 24rpx; + color: #6a7282; +} + +/* 评价列表 */ +.review-list { + flex: 1; + padding: 0 32rpx; + overflow-y: auto; +} + +.review-item { + background: #fff; + border-radius: 0; + padding: 32rpx; + margin-bottom: 2rpx; + border-bottom: 2rpx solid #f3f4f6; +} + +.review-item:first-child { + margin-top: 0; +} + +/* 用户信息行 */ +.review-user-row { + display: flex; + gap: 24rpx; + margin-bottom: 24rpx; +} + +.review-user-avatar { + width: 80rpx; + height: 80rpx; + flex-shrink: 0; +} + +.user-avatar-img { + width: 80rpx; + height: 80rpx; + border-radius: 50%; +} + +.user-avatar-placeholder { + width: 80rpx; + height: 80rpx; + border-radius: 50%; + background: #e5e7eb; + display: flex; + align-items: center; + justify-content: center; +} + +.user-avatar-text { + font-size: 28rpx; + color: #6a7282; +} + +.review-user-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 8rpx; +} + +.user-name-row { + display: flex; + justify-content: space-between; + align-items: center; +} + +.user-name { + font-size: 32rpx; + color: #101828; +} + +.review-date { + font-size: 24rpx; + color: #99a1af; +} + +.review-stars { + display: flex; + gap: 4rpx; +} + +.star-icon-small { + font-size: 24rpx; +} + +/* 评价内容 */ +.review-content-wrap { + margin-bottom: 24rpx; +} + +.review-content { + font-size: 28rpx; + color: #364153; + line-height: 1.625; +} + +.review-content.collapsed { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.expand-btn { + font-size: 28rpx; + color: #2b7fff; + margin-top: 8rpx; + display: inline-block; +} + +/* 评价标签 */ +.review-tags { + display: flex; + flex-wrap: wrap; + gap: 16rpx; + margin-bottom: 24rpx; +} + +.review-tag { + background: #fff7ed; + border-radius: 100rpx; + padding: 6rpx 20rpx; +} + +.review-tag-text { + font-size: 24rpx; + color: #f54900; +} + +/* 咨询师回复 */ +.counselor-reply { + background: #f9fafb; + border-radius: 20rpx; + padding: 24rpx; + margin-bottom: 24rpx; +} + +.reply-content { + display: flex; + flex-direction: column; + gap: 8rpx; +} + +.reply-label { + font-size: 24rpx; + color: #6a7282; +} + +.reply-text { + font-size: 24rpx; + color: #4a5565; + line-height: 1.625; +} + +/* 点赞操作 */ +.review-actions { + display: flex; + justify-content: flex-end; +} + +.like-btn { + display: flex; + align-items: center; + gap: 8rpx; +} + +.like-btn.liked { + opacity: 0.6; +} + +.like-btn.liked .like-count { + color: #ff4d6d; +} + +.like-icon { + font-size: 32rpx; +} + +.like-count { + font-size: 24rpx; + color: #99a1af; +} + +/* 底部提示 */ +.review-bottom-tip { + text-align: center; + padding: 32rpx 0; +} + +.bottom-tip-text { + font-size: 28rpx; + color: #99a1af; +} + +.review-loading { + text-align: center; + padding: 32rpx 0; +} + +.loading-text { + font-size: 28rpx; + color: #99a1af; +} + +/* ========== 语音录音相关样式 ========== */ + +/* 语音按钮激活状态 */ +.voice-btn.active { + background: #e8d5f0; + border-color: #b06ab3; +} + +.voice-icon-img { + width: 32rpx; + height: 32rpx; +} + +/* 按住说话按钮 */ +.voice-record-btn { + width: 100%; + height: 88rpx; + background: #f3f4f6; + border-radius: 100rpx; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.voice-record-btn.recording { + background: #e8d5f0; +} + +.voice-record-text { + font-size: 32rpx; + color: #4a5565; + font-weight: 500; +} + +.voice-record-btn.recording .voice-record-text { + color: #b06ab3; +} + +/* 录音中遮罩层 */ +.recording-mask { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; +} + +/* 录音弹窗 */ +.recording-popup { + width: 360rpx; + padding: 48rpx 32rpx; + background: rgba(0, 0, 0, 0.8); + border-radius: 32rpx; + display: flex; + flex-direction: column; + align-items: center; + gap: 24rpx; +} + +.recording-popup.cancel { + background: rgba(220, 38, 38, 0.9); +} + +/* 录音图标 */ +.recording-icon-wrap { + width: 120rpx; + height: 120rpx; + background: rgba(255, 255, 255, 0.1); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +/* 录音波形动画 */ +.recording-wave { + display: flex; + align-items: center; + gap: 8rpx; + height: 60rpx; +} + +.wave-bar { + width: 8rpx; + height: 20rpx; + background: #fff; + border-radius: 4rpx; + animation: waveAnimation 0.5s ease-in-out infinite alternate; +} + +.wave-bar:nth-child(1) { animation-delay: 0s; } +.wave-bar:nth-child(2) { animation-delay: 0.1s; } +.wave-bar:nth-child(3) { animation-delay: 0.2s; } +.wave-bar:nth-child(4) { animation-delay: 0.1s; } +.wave-bar:nth-child(5) { animation-delay: 0s; } + +@keyframes waveAnimation { + from { + height: 20rpx; + } + to { + height: 50rpx; + } +} + +.recording-popup.cancel .wave-bar { + animation: none; + height: 20rpx; +} + +/* 录音提示文字 */ +.recording-tip { + font-size: 28rpx; + color: rgba(255, 255, 255, 0.8); + text-align: center; +} + +/* 录音时长 */ +.recording-duration { + font-size: 48rpx; + font-weight: 700; + color: #fff; +} + + +/* ==================== 更多功能面板样式 ==================== */ +.more-panel { + background: #F5F5F5; + border-top: 2rpx solid #E5E7EB; +} + +.more-panel-content { + padding: 40rpx 32rpx 24rpx; +} + +.more-grid { + display: flex; + justify-content: space-between; +} + +.more-grid.second-row { + margin-top: 40rpx; +} + +.more-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 16rpx; + width: 25%; +} + +.more-icon-wrap { + width: 112rpx; + height: 112rpx; + background: #FFFFFF; + border-radius: 24rpx; + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.more-icon-img { + width: 56rpx; + height: 56rpx; +} + +.more-text { + font-size: 26rpx; + font-weight: 500; + color: #4A5565; +} + +.new-tag { + position: absolute; + top: -8rpx; + right: -8rpx; + background: #FF6B35; + color: #FFFFFF; + font-size: 18rpx; + font-weight: 700; + padding: 4rpx 10rpx; + border-radius: 8rpx; + line-height: 1; +} + +/* 相册图标 */ +.album-icon { + width: 48rpx; + height: 40rpx; + position: relative; +} + +.album-frame { + width: 100%; + height: 100%; + border: 4rpx solid #9CA3AF; + border-radius: 6rpx; + position: relative; +} + +.album-sun { + position: absolute; + top: 8rpx; + right: 8rpx; + width: 12rpx; + height: 12rpx; + background: #FBBF24; + border-radius: 50%; +} + +.album-mountain { + position: absolute; + bottom: 4rpx; + left: 50%; + transform: translateX(-50%); + width: 0; + height: 0; + border-left: 12rpx solid transparent; + border-right: 12rpx solid transparent; + border-bottom: 16rpx solid #10B981; +} + +/* 常用语图标 */ +.quick-reply-icon { + width: 48rpx; + height: 40rpx; + position: relative; +} + +.reply-bubble1 { + position: absolute; + top: 0; + left: 0; + width: 32rpx; + height: 24rpx; + background: #E5E7EB; + border-radius: 12rpx 12rpx 12rpx 4rpx; +} + +.reply-bubble2 { + position: absolute; + bottom: 0; + right: 0; + width: 32rpx; + height: 24rpx; + background: #914584; + border-radius: 12rpx 12rpx 4rpx 12rpx; +} + +/* 红包图标 */ +.red-packet-icon { + width: 40rpx; + height: 52rpx; + position: relative; +} + +.packet-body { + width: 100%; + height: 100%; + background: #EF4444; + border-radius: 8rpx; +} + +.packet-top { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 20rpx; + background: #DC2626; + border-radius: 8rpx 8rpx 0 0; +} + +.packet-circle { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 16rpx; + height: 16rpx; + background: #FBBF24; + border-radius: 50%; +} + +.more-panel-safe { + height: env(safe-area-inset-bottom); + background: #F5F5F5; +} + +/* +号按钮激活状态 */ +.add-btn.active { + background: #E5E7EB; + border-radius: 8rpx; +} + +/* 表情按钮激活状态 */ +.emoji-btn.active { + background: #E5E7EB; + border-radius: 8rpx; +} + +/* ==================== 表情面板样式 ==================== */ +.emoji-panel { + background: #FFFFFF; + border-top: 2rpx solid #F3F4F6; +} + +.emoji-scroll { + height: 480rpx; + padding: 24rpx; + box-sizing: border-box; +} + +.emoji-grid { + display: flex; + flex-wrap: wrap; +} + +.emoji-item { + width: 12.5%; + height: 88rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.emoji-text { + font-size: 56rpx; + line-height: 1; +} + +.emoji-item:active { + background: #F3F4F6; + border-radius: 16rpx; +} + +/* 表情面板底部操作 */ +.emoji-actions { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 24rpx; + padding: 16rpx 32rpx; + border-top: 2rpx solid #F3F4F6; + padding-bottom: calc(16rpx + env(safe-area-inset-bottom)); + background: #FFFFFF; +} + +.emoji-delete { + width: 80rpx; + height: 72rpx; + background: #F3F4F6; + border-radius: 12rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.delete-icon { + width: 40rpx; + height: 40rpx; + transform: rotate(180deg); +} + +.emoji-send { + width: 120rpx; + height: 72rpx; + background: #E5E7EB; + border-radius: 12rpx; + display: flex; + align-items: center; + justify-content: center; + font-size: 28rpx; + color: #9CA3AF; +} + +.emoji-send.active { + background: #914584; + color: #FFFFFF; +} diff --git a/pages/custom/custom.js b/pages/custom/custom.js new file mode 100644 index 0000000..11fc013 --- /dev/null +++ b/pages/custom/custom.js @@ -0,0 +1,46 @@ +const api = require('../../utils/api') + +Page({ + data: { + info: null, + loading: true, + error: null + }, + + onLoad() { + this.loadCustomInfo() + }, + + onBack() { + wx.navigateBack() + }, + + async loadCustomInfo() { + this.setData({ loading: true, error: null }) + + try { + const res = await api.common.getBrandConfig() + console.log('[custom] 定制服务配置响应:', res) + + if (res.success && res.data) { + const data = res.data + const customService = data.custom_service || {} + + this.setData({ + info: { + title: '定制服务', + content: customService.value || '' + } + }) + console.log('[custom] 定制服务信息:', this.data.info) + } else { + this.setData({ error: res.error || '加载失败' }) + } + } catch (err) { + console.error('[custom] 加载失败:', err) + this.setData({ error: err.message || '加载失败' }) + } finally { + this.setData({ loading: false }) + } + } +}) diff --git a/pages/custom/custom.json b/pages/custom/custom.json new file mode 100644 index 0000000..8835af0 --- /dev/null +++ b/pages/custom/custom.json @@ -0,0 +1,3 @@ +{ + "usingComponents": {} +} \ No newline at end of file diff --git a/pages/custom/custom.wxml b/pages/custom/custom.wxml new file mode 100644 index 0000000..a9a4554 --- /dev/null +++ b/pages/custom/custom.wxml @@ -0,0 +1,38 @@ + + + + + + 返回 + + {{info.title}} + + + + + + + 加载中... + + + + + {{error}} + 点击重试 + + + + + + + + + + + + + + 暂无内容 + + + diff --git a/pages/custom/custom.wxss b/pages/custom/custom.wxss new file mode 100644 index 0000000..4cba098 --- /dev/null +++ b/pages/custom/custom.wxss @@ -0,0 +1,165 @@ +.page { + min-height: 100vh; + background-color: #f8f8f8; + display: flex; + flex-direction: column; +} + +.unified-header { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 194rpx; + background: linear-gradient(135deg, #B06AB3 0%, #9B59B6 100%); + border-bottom: none; + display: flex; + align-items: flex-end; + justify-content: space-between; + padding: 0 32rpx 20rpx; + z-index: 1000; +} + +.unified-header-left { + display: flex; + align-items: center; + gap: 8rpx; + width: 160rpx; + height: 56rpx; +} + +.unified-back-icon { + width: 56rpx; + height: 56rpx; +} + +.unified-back-text { + font-size: 34rpx; + font-weight: bold; + color: #ffffff; +} + +.unified-header-title { + font-size: 40rpx; + font-weight: bold; + color: #ffffff; + flex: 1; + text-align: center; +} + +.unified-header-right { + width: 160rpx; + height: 56rpx; +} + +.content { + flex: 1; +} + +.loading-tip, .error-tip, .empty-tip { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding-top: 200rpx; +} + +.loading-tip text, .empty-tip text, .error-tip text { + font-size: 28rpx; + color: #999; +} + +.error-tip text:first-child { + color: #ff6b6b; + margin-bottom: 20rpx; +} + +.retry-btn { + background: linear-gradient(135deg, #B06AB3 0%, #9B59B6 100%); + color: #fff !important; + padding: 16rpx 48rpx; + border-radius: 36rpx; + font-size: 28rpx !important; +} + +.empty-tip image { + width: 240rpx; + height: 240rpx; + margin-bottom: 20rpx; +} + +.custom-content { + padding: 30rpx; +} + +.intro-section { + background: #fff; + border-radius: 20rpx; + padding: 30rpx; + margin-bottom: 30rpx; +} + +.intro-section rich-text { + font-size: 28rpx; + color: #666; + line-height: 1.8; +} + +.section-title { + font-size: 32rpx; + font-weight: bold; + color: #333; + margin-bottom: 20rpx; + padding-bottom: 16rpx; + border-bottom: 2rpx solid #f0f0f0; +} + +.services-section { + background: #fff; + border-radius: 20rpx; + padding: 30rpx; +} + +.services-list { + display: flex; + flex-direction: column; + gap: 16rpx; + margin-top: 20rpx; +} + +.service-item { + display: flex; + align-items: center; + padding: 24rpx; + background: #f8f8f8; + border-radius: 16rpx; +} + +.service-icon-box { + width: 80rpx; + height: 80rpx; + background: linear-gradient(135deg, #B06AB3 0%, #9B59B6 100%); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-right: 20rpx; +} + +.service-info { + flex: 1; +} + +.service-name { + display: block; + font-size: 30rpx; + font-weight: bold; + color: #333; + margin-bottom: 8rpx; +} + +.service-desc { + display: block; + font-size: 24rpx; + color: #666; +} diff --git a/pages/customer-management/customer-management.js b/pages/customer-management/customer-management.js new file mode 100644 index 0000000..c573220 --- /dev/null +++ b/pages/customer-management/customer-management.js @@ -0,0 +1,233 @@ +// 客户管理页面 - 陪聊师端 +// 对接后端API + +const api = require('../../utils/api') +const app = getApp() + +Page({ + data: { + statusBarHeight: 44, + navHeight: 92, + loading: false, + // 统计数据 + balanceInt: '0', + balanceDec: '00', + pendingAmount: '0.00', + totalSettled: '0.00', + newCustomers: 0, + promotionOrders: 0, + // 搜索 + searchQuery: '', + // 客户列表 + customerList: [], + page: 1, + hasMore: true + }, + + onLoad() { + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + const navHeight = statusBarHeight + 48 + + this.setData({ + statusBarHeight, + navHeight + }) + + this.loadData() + }, + + /** + * 下拉刷新 + */ + onPullDownRefresh() { + this.loadData().then(() => { + wx.stopPullDownRefresh() + }) + }, + + /** + * 上拉加载更多 + */ + onReachBottom() { + if (this.data.hasMore && !this.data.loading) { + this.loadMoreCustomers() + } + }, + + /** + * 加载数据 + */ + async loadData() { + await Promise.all([ + this.loadStats(), + this.loadCustomers() + ]) + }, + + /** + * 加载统计数据 + */ + async loadStats() { + try { + const res = await api.companion.getOrderStats() + + if (res.success && res.data) { + const balance = res.data.balance || 0 + const balanceStr = balance.toFixed(2) + const [balanceInt, balanceDec] = balanceStr.split('.') + + this.setData({ + balanceInt: parseInt(balanceInt).toLocaleString(), + balanceDec: balanceDec || '00', + pendingAmount: (res.data.pending_amount || 0).toFixed(2), + totalSettled: (res.data.total_settled || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 }), + newCustomers: res.data.new_customers || 0, + promotionOrders: res.data.promotion_orders || 0 + }) + } + } catch (err) { + console.log('获取统计数据失败', err) + } + }, + + /** + * 加载客户列表 + */ + async loadCustomers() { + this.setData({ loading: true, page: 1 }) + + try { + const params = { page: 1, pageSize: 20 } + + if (this.data.searchQuery.trim()) { + params.keyword = this.data.searchQuery.trim() + } + + const res = await api.companion.getCustomers(params) + + if (res.success && res.data) { + const customers = (res.data.list || res.data || []).map(c => this.transformCustomer(c)) + + this.setData({ + customerList: customers, + hasMore: customers.length >= 20, + loading: false + }) + } else { + this.setData({ customerList: [], loading: false }) + } + } catch (err) { + console.error('加载客户列表失败', err) + this.setData({ loading: false }) + } + }, + + /** + * 加载更多客户 + */ + async loadMoreCustomers() { + const nextPage = this.data.page + 1 + this.setData({ loading: true }) + + try { + const params = { page: nextPage, pageSize: 20 } + + if (this.data.searchQuery.trim()) { + params.keyword = this.data.searchQuery.trim() + } + + const res = await api.companion.getCustomers(params) + + if (res.success && res.data) { + const newCustomers = (res.data.list || res.data || []).map(c => this.transformCustomer(c)) + + this.setData({ + customerList: [...this.data.customerList, ...newCustomers], + page: nextPage, + hasMore: newCustomers.length >= 20, + loading: false + }) + } else { + this.setData({ hasMore: false, loading: false }) + } + } catch (err) { + console.error('加载更多客户失败', err) + this.setData({ loading: false }) + } + }, + + /** + * 转换客户数据格式 + */ + transformCustomer(customer) { + return { + id: customer.id || customer.user_id, + displayId: `ID${String(customer.id || customer.user_id).slice(-4)}`, + nickname: customer.nickname || customer.name, + avatar: customer.avatar, + time: this.formatTime(customer.last_contact_at || customer.created_at), + status: customer.status || 'success', + orderCount: customer.order_count || 0, + totalAmount: customer.total_amount || 0 + } + }, + + /** + * 格式化时间 + */ + formatTime(dateStr) { + if (!dateStr) return '' + const date = new Date(dateStr) + const hour = String(date.getHours()).padStart(2, '0') + const minute = String(date.getMinutes()).padStart(2, '0') + return `${hour}:${minute}` + }, + + onBack() { + wx.navigateBack() + }, + + onSearchInput(e) { + this.setData({ searchQuery: e.detail.value }) + }, + + /** + * 搜索客户 + */ + onSearch() { + this.loadCustomers() + }, + + /** + * 点击客户 + */ + onCustomerTap(e) { + const customer = e.currentTarget.dataset.customer || {} + const id = e.currentTarget.dataset.id || customer.id + + wx.showActionSheet({ + itemList: ['查看详情', '发起聊天', '查看订单'], + success: (res) => { + if (res.tapIndex === 0) { + // 查看详情 + wx.showModal({ + title: '客户详情', + content: `客户ID: ${customer.displayId || id}\n订单数: ${customer.orderCount || 0}\n消费金额: ¥${customer.totalAmount || 0}`, + showCancel: false + }) + } else if (res.tapIndex === 1) { + // 发起聊天 + wx.navigateTo({ + url: `/pages/companion-chat/companion-chat?customerId=${id}` + }) + } else if (res.tapIndex === 2) { + // 查看订单 + wx.navigateTo({ + url: `/pages/orders/orders?customerId=${id}` + }) + } + } + }) + } +}) diff --git a/pages/customer-management/customer-management.json b/pages/customer-management/customer-management.json new file mode 100644 index 0000000..e90e996 --- /dev/null +++ b/pages/customer-management/customer-management.json @@ -0,0 +1,4 @@ +{ + "usingComponents": {}, + "navigationStyle": "custom" +} diff --git a/pages/customer-management/customer-management.wxml b/pages/customer-management/customer-management.wxml new file mode 100644 index 0000000..803ef9b --- /dev/null +++ b/pages/customer-management/customer-management.wxml @@ -0,0 +1,132 @@ + + + + + + + + + 返回 + + + + + + + + + + + + + + + + 快捷查询 + + + + + + + + + + + + 新增客户 + + + + + + {{newCustomers}} + + +5% + + + + + + + + + 推广订单 + + + + + + {{promotionOrders}} + + 66%转化 + + + + + + + + + 今日列表 + + + 实时更新 + + + + + + + {{item.id}} + {{item.time}} + + + + {{item.status === 'success' ? '注册成功' : '待确认'}} + + + + + + + + + + diff --git a/pages/customer-management/customer-management.wxss b/pages/customer-management/customer-management.wxss new file mode 100644 index 0000000..cb66d65 --- /dev/null +++ b/pages/customer-management/customer-management.wxss @@ -0,0 +1,508 @@ +/* 客户管理页面样式 */ +page { + background: #f5f2fd; +} + +.page-container { + min-height: 100vh; + background: #f5f2fd; + width: 100%; + overflow-x: hidden; + box-sizing: border-box; +} + +/* 隐藏滚动条 */ +.content-scroll::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} + +.content-scroll { + -ms-overflow-style: none; + scrollbar-width: none; +} + +/* 顶部导航 */ +.header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; +} + +.nav-bar { + display: flex; + align-items: center; + height: 48px; + padding: 0 16px; +} + +.back-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 10px 8px; + border-radius: 50px; +} + +.back-icon { + width: 28px; + height: 28px; +} + +.back-text { + font-size: 18px; + font-weight: 700; + color: #101828; +} + +/* 内容区域 */ +.content-scroll { + min-height: 100vh; + padding: 0 16px; + box-sizing: border-box; + width: 100%; +} + +/* 账户卡片 */ +.account-card { + position: relative; + background: linear-gradient(180deg, #b06ab3 0%, #d489be 100%); + border-radius: 24px; + padding: 24px; + margin-bottom: 24px; + overflow: hidden; + box-shadow: 0 8px 24px rgba(176, 106, 179, 0.3); + box-sizing: border-box; + width: 100%; +} + +.account-bg { + position: absolute; + top: -48px; + right: -48px; + width: 192px; + height: 192px; + background: rgba(255, 255, 255, 0.1); + border-radius: 50%; + filter: blur(40px); +} + +.account-content { + position: relative; + z-index: 1; +} + +.account-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 24px; +} + +.account-icon-wrap { + width: 30px; + height: 30px; + background: rgba(255, 255, 255, 0.2); + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; +} + +.account-icon { + width: 18px; + height: 18px; +} + +.account-title { + font-size: 16px; + font-weight: 700; + color: #fff; + opacity: 0.95; +} + +.balance-section { + margin-bottom: 24px; +} + +.balance-label { + font-size: 14px; + color: rgba(255, 255, 255, 0.8); + margin-bottom: 6px; + display: block; +} + +.balance-value { + display: flex; + align-items: baseline; +} + +.balance-int { + font-size: 48px; + font-weight: 900; + color: #fff; + line-height: 1; +} + +.balance-dec { + font-size: 30px; + font-weight: 700; + color: rgba(255, 255, 255, 0.9); +} + +.stats-box { + background: rgba(0, 0, 0, 0.1); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 16px; + overflow: hidden; +} + +.stat-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; +} + +.stat-left { + display: flex; + align-items: center; + gap: 8px; +} + +.stat-icon { + width: 16px; + height: 16px; + opacity: 0.9; +} + +.stat-label { + font-size: 14px; + font-weight: 700; + color: rgba(255, 255, 255, 0.95); +} + +.stat-value { + font-size: 20px; + font-weight: 700; + color: #fff; +} + +.stat-divider { + height: 1px; + background: rgba(255, 255, 255, 0.1); + margin: 0 16px; +} + +/* 快捷查询 */ +.search-card { + background: #fff; + border-radius: 24px; + padding: 20px; + margin-bottom: 16px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); + box-sizing: border-box; + width: 100%; +} + +.search-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 20px; +} + +.search-icon-wrap { + width: 34px; + height: 34px; + background: #fdf4f9; + border-radius: 14px; + display: flex; + align-items: center; + justify-content: center; +} + +.search-icon { + width: 18px; + height: 18px; +} + +.search-title { + font-size: 16px; + font-weight: 700; + color: #101828; +} + +.search-input-wrap { + width: 100%; + box-sizing: border-box; +} + +.search-input { + width: 100%; + height: 48px; + background: #f9fafb; + border-radius: 16px; + padding: 0 16px; + font-size: 14px; + box-sizing: border-box; +} + +.search-input::placeholder { + color: #99a1af; +} + +.search-btn { + width: 100%; + margin-top: 12px; + background: #b06ab3; + color: #fff; + font-size: 14px; + font-weight: 700; + padding: 12px 0; + border-radius: 14px; + border: none; + box-shadow: 0 4px 8px rgba(176, 106, 179, 0.2); +} + +.search-btn::after { + border: none; +} + +/* 统计卡片 */ +.stats-cards { + display: flex; + gap: 12px; + margin-bottom: 16px; + width: 100%; + box-sizing: border-box; +} + +.stat-card { + flex: 1; + min-width: 0; + background: #fff; + border-radius: 16px; + padding: 16px; + position: relative; + overflow: hidden; + border: 1px solid #f3f4f6; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); + box-sizing: border-box; +} + +.stat-card.active { + border: 1.1px solid #b06ab3; + box-shadow: 0 0 0 1px rgba(176, 106, 179, 0.2), 0 4px 6px -1px rgba(176, 106, 179, 0.1); +} + +.stat-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.stat-card-title { + font-size: 14px; + font-weight: 700; + color: #6a7282; +} + +.stat-card.active .stat-card-title { + color: #b06ab3; +} + +.stat-card-icon-wrap { + width: 30px; + height: 30px; + background: #fdf4f9; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; +} + +.stat-card-icon-wrap.inactive { + background: #f3f4f6; +} + +.stat-card-icon { + width: 18px; + height: 18px; +} + +.stat-card-body { + display: flex; + align-items: flex-end; + gap: 8px; +} + +.stat-card-value { + font-size: 30px; + font-weight: 900; + color: #101828; + line-height: 1; +} + +.stat-card-value.inactive { + color: #99a1af; +} + +.stat-badge { + display: flex; + align-items: center; + gap: 2px; + padding: 2px 6px; + border-radius: 8px; + font-size: 12px; + font-weight: 700; + margin-bottom: 4px; +} + +.stat-badge.success { + background: #f0fdf4; + color: #00a63e; +} + +.stat-badge.neutral { + background: #f3f4f6; + color: #99a1af; +} + +.badge-icon { + width: 10px; + height: 10px; +} + +.stat-card-indicator { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 4px; + background: #b06ab3; +} + +/* 今日列表 */ +.list-card { + background: #fff; + border-radius: 24px; + padding: 24px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); + box-sizing: border-box; + width: 100%; +} + +.list-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + padding: 0 4px; +} + +.list-title { + font-size: 18px; + font-weight: 700; + color: #101828; +} + +.live-badge { + display: flex; + align-items: center; + gap: 8px; + background: #f0fdf4; + padding: 4px 10px; + border-radius: 10px; +} + +.live-dot { + width: 6px; + height: 6px; + background: #00c950; + border-radius: 50%; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.live-text { + font-size: 12px; + font-weight: 700; + color: #101828; +} + +.customer-list { + border-top: 1px solid #f9fafb; + padding-top: 8px; +} + +.customer-item { + display: flex; + align-items: center; + justify-content: space-between; + height: 57px; + padding: 0 8px; + border-bottom: 1px solid #f9fafb; +} + +.customer-item:last-child { + border-bottom: none; +} + +.customer-left { + display: flex; + align-items: center; + gap: 12px; +} + +.customer-id { + font-size: 16px; + font-weight: 700; + color: #101828; +} + +.customer-time { + font-size: 14px; + color: #99a1af; +} + +.customer-right { + display: flex; + align-items: center; + gap: 8px; +} + +.status-badge { + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 700; +} + +.status-badge.success { + background: #f0fdf4; + color: #00a63e; +} + +.status-badge.pending { + background: #fef3c7; + color: #d97706; +} + +.chevron-icon { + width: 16px; + height: 16px; + opacity: 0.5; +} + +.bottom-space { + height: 60px; +} diff --git a/pages/edit-profile/edit-profile.js b/pages/edit-profile/edit-profile.js new file mode 100644 index 0000000..825c1a5 --- /dev/null +++ b/pages/edit-profile/edit-profile.js @@ -0,0 +1,323 @@ +const { request, getBaseUrl } = require('../../utils_new/request'); +const util = require('../../utils/util'); + +Page({ + data: { + statusBarHeight: 20, + navBarHeight: 44, + totalNavHeight: 64, + defaultAvatar: '/images/default-avatar.svg', + loading: true, + saving: false, + uploading: false, + genderRange: ['男', '女'], + ageRanges: ['18-24岁', '25-35岁', '36-45岁', '46-55岁', '56岁以上'], + isProfileCompleted: false, + originalAvatar: '', + avatarUploadTemp: '', + form: { + nickname: '', + avatar: '', + gender: 0, // 1: 男, 2: 女 + age_range: '', + region: '', + phone: '' // 增加手机号显示 + } + }, + onLoad() { + const sys = wx.getSystemInfoSync(); + const menu = wx.getMenuButtonBoundingClientRect(); + const statusBarHeight = sys.statusBarHeight || 20; + const navBarHeight = menu.height + (menu.top - statusBarHeight) * 2; + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight: statusBarHeight + navBarHeight + }); + this.load(); + }, + onBack() { + wx.navigateBack({ delta: 1 }); + }, + async load() { + this.setData({ loading: true }); + try { + let profileData = { + nickname: '', + avatar: '', + gender: 0, + age_range: '', + region: '', + phone: '' + }; + let isProfileCompleted = false; + + try { + const res = await request({ url: '/api/users/profile', method: 'GET' }); + if (res.data && res.data.code === 0) { + const data = res.data.data; + profileData = { + nickname: data.nickname || '', + avatar: util.getFullImageUrl(data.avatar) || '', + gender: Number(data.gender || 0), + age_range: data.ageRange || data.age_range || '', + region: data.region || '', + phone: data.phone || '' + }; + isProfileCompleted = !!data.profile_completed; + } + } catch (err) { + console.log('API load failed'); + } + + this.setData({ + form: profileData, + isProfileCompleted, + originalAvatar: profileData.avatar || '' + }); + } finally { + this.setData({ loading: false }); + } + }, + + // 格式化图片地址,处理相对路径 + formatImageUrl(url) { + return util.getFullImageUrl(url); + }, + + // 将完整URL转回相对路径 + getRelativeUrl(url) { + if (!url) return ''; + if (url.startsWith('wxfile://') || url.startsWith('data:')) return ''; + const baseUrl = getBaseUrl(); + if (url.startsWith(baseUrl)) { + return url.replace(baseUrl, ''); + } + try { + if (url.startsWith('http://') || url.startsWith('https://')) { + const parsed = new URL(url); + const path = `${parsed.pathname}${parsed.search || ''}`; + return path; + } + } catch (_) {} + return url; + }, + + onChooseAvatar(e) { + if (e.detail.errMsg && e.detail.errMsg.indexOf('fail') !== -1) { + console.error('头像选择失败:', e.detail.errMsg); + // 如果是开发者工具的 Bug,提示用户重启 + if (e.detail.errMsg.indexOf('not found') !== -1) { + wx.showModal({ + title: '上传提示', + content: '微信开发者工具临时文件异常,请尝试重新点击或重启开发者工具。', + showCancel: false + }); + } + return; + } + + const { avatarUrl } = e.detail; + if (!avatarUrl) return; + + // 微信头像临时路径先显示,再上传 + this.setData({ + 'form.avatar': avatarUrl, + avatarUploadTemp: avatarUrl + }); + this.uploadAvatar(avatarUrl); + }, + + onNicknameInput(e) { + this.setData({ + 'form.nickname': e.detail.value + }); + }, + + onNicknameBlur(e) { + this.setData({ + 'form.nickname': e.detail.value + }); + }, + + onGenderChange(e) { + const idx = Number(e.detail.value); + const gender = idx + 1; // 0 -> 1 (男), 1 -> 2 (女) + this.setData({ + 'form.gender': gender + }); + }, + + onAgeRange(e) { + const idx = Number(e.detail.value); + const age_range = this.data.ageRanges[idx] || ''; + this.setData({ + 'form.age_range': age_range + }); + }, + + onRegion(e) { + const regionArray = e.detail.value || []; + const region = regionArray.join(' '); + this.setData({ + 'form.region': region, + 'form.regionArray': regionArray + }); + }, + + uploadAvatar(filePath) { + this.setData({ uploading: true }); + const token = wx.getStorageSync('auth_token') || ''; + const baseUrl = getBaseUrl(); + + // 获取设备ID + const deviceId = wx.getStorageSync('deviceId') || wx.getStorageSync('user_id') || 'unknown'; + + wx.uploadFile({ + url: `${baseUrl}/api/upload`, + filePath, + name: 'file', + formData: { folder: 'avatars' }, + header: { + 'x-device-id': deviceId, + ...(token ? { Authorization: `Bearer ${token}` } : {}) + }, + success: (res) => { + try { + const body = JSON.parse(res.data || '{}'); + if (body.code === 0 && body.data?.url) { + const fullUrl = this.formatImageUrl(body.data.url); + this.setData({ 'form.avatar': fullUrl, avatarUploadTemp: '' }); + wx.showToast({ title: '头像上传成功', icon: 'success' }); + } else { + wx.showToast({ title: body.message || '上传失败', icon: 'none' }); + this.setData({ + 'form.avatar': this.data.originalAvatar || '', + avatarUploadTemp: '' + }); + } + } catch (e) { + wx.showToast({ title: '解析返回失败', icon: 'none' }); + this.setData({ + 'form.avatar': this.data.originalAvatar || '', + avatarUploadTemp: '' + }); + } + }, + fail: (err) => { + console.error('upload failed', err); + wx.showToast({ title: '网络上传失败', icon: 'none' }); + this.setData({ + 'form.avatar': this.data.originalAvatar || '', + avatarUploadTemp: '' + }); + }, + complete: () => { + this.setData({ uploading: false }); + } + }); + }, + + onAvatarError() { + this.setData({ + 'form.avatar': this.data.defaultAvatar + }); + }, + + async save() { + if (this.data.saving) return; + if (this.data.uploading) { + wx.showToast({ title: '头像上传中,请稍候', icon: 'none' }); + return; + } + const form = this.data.form; + + const avatar = String(form.avatar || '').trim(); + if (avatar.startsWith('wxfile://') || avatar.startsWith('data:')) { + wx.showToast({ title: '头像尚未上传成功,请重新上传', icon: 'none' }); + return; + } + + // 验证完整信息 + if (!form.avatar || form.avatar === this.data.defaultAvatar) { + wx.showToast({ title: '请设置头像', icon: 'none' }); + return; + } + if (!String(form.nickname || '').trim()) { + wx.showToast({ title: '请输入昵称', icon: 'none' }); + return; + } + if (form.gender !== 1 && form.gender !== 2) { + wx.showToast({ title: '请选择性别', icon: 'none' }); + return; + } + if (!form.age_range) { + wx.showToast({ title: '请选择年龄段', icon: 'none' }); + return; + } + if (!form.region) { + wx.showToast({ title: '请选择所在地区', icon: 'none' }); + return; + } + + this.setData({ saving: true }); + try { + const payload = { + nickname: String(form.nickname || '').trim(), + avatar: this.getRelativeUrl(form.avatar), + gender: Number(form.gender), + age_range: form.age_range, + ageRange: form.age_range, + region: form.region, + regionArray: Array.isArray(form.regionArray) ? form.regionArray : undefined + }; + const res = await request({ + url: '/api/users/profile', + method: 'PUT', + data: payload + }); + const body = res.data || {}; + const ok = + (typeof body.code === 'number' && body.code === 0) || + (typeof body.success === 'boolean' && body.success === true); + if (!ok) throw new Error(body.message || body.error || '保存失败'); + + try { + const rewardRes = await request({ + url: '/api/love-points/profile-complete', + method: 'POST', + data: {} + }); + const rewardData = rewardRes.data || {}; + const innerData = rewardData.data || {}; + + if (rewardData.success && (innerData.earned > 0 || innerData.alreadyClaimed)) { + if (innerData.earned > 0) { + wx.showToast({ title: rewardData.message || `获得 ${innerData.earned} 爱心值`, icon: 'success' }); + } else { + wx.showToast({ title: '保存成功', icon: 'success' }); + } + this.setData({ isProfileCompleted: true }); + } else { + wx.showToast({ title: '保存成功', icon: 'success' }); + } + } catch (_) { + wx.showToast({ title: '保存成功', icon: 'success' }); + } + + // CRITICAL: Refresh global user info to ensure avatar consistency + const app = getApp(); + if (app && app.refreshUserInfo) { + app.refreshUserInfo(); + } + + this.setData({ originalAvatar: form.avatar || '' }); + + setTimeout(() => wx.navigateBack({ delta: 1 }), 500); + } catch (e) { + wx.showToast({ title: e.message || '保存失败', icon: 'none' }); + } finally { + this.setData({ saving: false }); + } + } +}); diff --git a/pages/edit-profile/edit-profile.json b/pages/edit-profile/edit-profile.json new file mode 100644 index 0000000..3153ca5 --- /dev/null +++ b/pages/edit-profile/edit-profile.json @@ -0,0 +1,5 @@ +{ + "usingComponents": { + "app-icon": "/components/icon/icon" + } +} diff --git a/pages/edit-profile/edit-profile.wxml b/pages/edit-profile/edit-profile.wxml new file mode 100644 index 0000000..681a62b --- /dev/null +++ b/pages/edit-profile/edit-profile.wxml @@ -0,0 +1,100 @@ + + + + + + + + + 个人信息 + + + + + + + + + + + + + 昵称 + + + + + + + 性别 + + + + {{form.gender === 1 ? '男' : (form.gender === 2 ? '女' : '去选择')}} + + + + + + + + 年龄段 + + + + {{form.age_range || '去选择'}} + + + + + + + + 地区 + + + + {{form.region || '去选择'}} + + + + + + + + + + + 手机号 + + {{form.phone || '未绑定'}} + + + + + + + + + + + 完善资料可获得 100 爱心值 + + + + diff --git a/pages/edit-profile/edit-profile.wxss b/pages/edit-profile/edit-profile.wxss new file mode 100644 index 0000000..e71231c --- /dev/null +++ b/pages/edit-profile/edit-profile.wxss @@ -0,0 +1,172 @@ +.page { + min-height: 100vh; + background-color: #F7F7F7; + padding-bottom: env(safe-area-inset-bottom); +} + +/* 导航栏样式 */ +.nav-container { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 999; + background-color: #F7F7F7; +} + +.nav-bar { + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.nav-back { + position: absolute; + left: 16px; + top: 50%; + transform: translateY(-50%); + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; +} + +.back-icon { + width: 24px; + height: 24px; +} + +.nav-title { + font-size: 17px; + font-weight: 600; + color: #000000; +} + +/* 容器样式 */ +.container { + padding-bottom: 40px; +} + +/* 单元格组样式 */ +.cell-group { + margin-top: 12px; + background-color: #FFFFFF; + border-top: 0.5px solid #EEEEEE; + border-bottom: 0.5px solid #EEEEEE; +} + +.cell { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + background-color: #FFFFFF; + line-height: 1.4; + font-size: 17px; + border: none; + border-radius: 0; + margin: 0; + width: 100% !important; + font-weight: normal; +} + +.cell::after { + display: none; +} + +.cell:not(:last-child) { + border-bottom: 0.5px solid #EEEEEE; + margin-left: 16px; + padding-left: 0; +} + +.cell-label { + color: #333333; + width: 80px; + text-align: left; +} + +.cell-value { + flex: 1; + display: flex; + align-items: center; + justify-content: flex-end; + color: #000000; + text-align: right; +} + +/* 头像单元格 */ +.avatar-cell { + padding: 12px 16px; +} + +.avatar { + width: 56px; + height: 56px; + border-radius: 8px; + margin-right: 8px; + background-color: #F0F0F0; +} + +/* 输入框样式 */ +.cell-input { + width: 100%; + height: 24px; + text-align: right; + color: #000000; +} + +.picker-value { + flex: 1; + margin-right: 4px; +} + +.picker-value.placeholder { + color: #CCCCCC; +} + +.phone-text { + color: #999999; +} + +.readonly-cell { + background-color: #FFFFFF; +} + +/* 按钮区域 */ +.btn-area { + margin-top: 40px; + padding: 0 16px; + display: flex; + flex-direction: column; + align-items: center; +} + +.save-btn { + width: 184px !important; + height: 48px; + line-height: 48px; + background-color: #07C160; + color: #FFFFFF; + font-size: 17px; + font-weight: 600; + border-radius: 8px; + border: none; + padding: 0; +} + +.save-btn[disabled] { + background-color: #F2F2F2; + color: #CCCCCC; +} + +.reward-tip { + margin-top: 16px; + display: flex; + align-items: center; + gap: 4px; + font-size: 14px; + color: #B06AB3; +} diff --git a/pages/eldercare-apply/eldercare-apply.js b/pages/eldercare-apply/eldercare-apply.js new file mode 100644 index 0000000..7049117 --- /dev/null +++ b/pages/eldercare-apply/eldercare-apply.js @@ -0,0 +1,162 @@ +// pages/eldercare-apply/eldercare-apply.js +// 智慧养老申请页面 +const api = require('../../utils/api') + +Page({ + data: { + statusBarHeight: 44, + navBarHeight: 44, + totalNavHeight: 88, + showForm: true, + applyStatus: 'none', + statusTitle: '', + statusDesc: '', + isReapply: false, + agreed: false, + formData: { + realName: '', + city: '', + phone: '', + remarks: '' + }, + canSubmit: false + }, + + onLoad(options) { + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + const menuButton = wx.getMenuButtonBoundingClientRect() + const navBarHeight = menuButton.height + (menuButton.top - statusBarHeight) * 2 + const totalNavHeight = statusBarHeight + navBarHeight + + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight, + isReapply: options.isReapply === 'true' + }) + + this.checkApplyStatus() + }, + + goBack() { + wx.navigateBack() + }, + + async checkApplyStatus() { + const token = wx.getStorageSync('auth_token') + if (!token) { + this.setData({ applyStatus: 'none' }) + return + } + + try { + const res = await api.request('/eldercare/apply') + if (res.success && res.data) { + const data = res.data + if (data.status === 'approved') { + this.setData({ + applyStatus: 'approved', + statusTitle: '申请已通过', + statusDesc: '恭喜您成为养老护理师!' + }) + } else if (data.status === 'pending') { + this.setData({ + applyStatus: 'pending', + statusTitle: '审核中', + statusDesc: '您的申请正在审核中,请耐心等待' + }) + } else if (data.status === 'rejected') { + this.setData({ + applyStatus: 'rejected', + statusTitle: '申请未通过', + statusDesc: data.rejectReason || '很抱歉,您的申请未通过审核' + }) + } + } + } catch (err) { + console.log('获取申请状态失败:', err) + this.setData({ applyStatus: 'none' }) + } + }, + + reapply() { + this.setData({ isReapply: true, applyStatus: 'none' }) + }, + + onInputChange(e) { + const field = e.currentTarget.dataset.field + this.setData({ [`formData.${field}`]: e.detail.value }) + this.checkCanSubmit() + }, + + toggleAgreement() { + this.setData({ agreed: !this.data.agreed }) + this.checkCanSubmit() + }, + + viewAgreement() { + wx.navigateTo({ url: '/pages/agreement/agreement?code=cooperation_service' }) + }, + + checkCanSubmit() { + const { formData, agreed } = this.data + + const canSubmit = + formData.realName && + formData.phone && + formData.phone.length === 11 && + agreed + + this.setData({ canSubmit }) + }, + + async submitApply() { + if (!this.data.canSubmit) return + + const { formData } = this.data + + if (!/^1[3-9]\d{9}$/.test(formData.phone)) { + wx.showToast({ title: '请输入正确的手机号', icon: 'none' }) + return + } + + wx.showLoading({ title: '提交中...' }) + try { + const res = await api.request('/eldercare/apply', { + method: 'POST', + data: { + realName: formData.realName, + city: formData.city, + phone: formData.phone, + remarks: formData.remarks + } + }) + + if (res.success || res.code === 0) { + wx.showToast({ title: '申请已提交', icon: 'success' }) + this.setData({ + applyStatus: 'pending', + statusTitle: '审核中', + statusDesc: '您的申请正在审核中,请耐心等待', + isReapply: false + }) + } else { + wx.showToast({ title: res.message || '提交失败', icon: 'none' }) + } + } catch (err) { + if (err.code === 404) { + wx.showModal({ + title: '提示', + content: '该服务申请即将开放,敬请期待!', + showCancel: false, + confirmColor: '#b06ab3' + }) + } else { + wx.showToast({ title: err.message || '提交失败', icon: 'none' }) + } + } finally { + wx.hideLoading() + } + } +}) diff --git a/pages/eldercare-apply/eldercare-apply.json b/pages/eldercare-apply/eldercare-apply.json new file mode 100644 index 0000000..a3e86ce --- /dev/null +++ b/pages/eldercare-apply/eldercare-apply.json @@ -0,0 +1,5 @@ +{ + "usingComponents": {}, + "navigationStyle": "custom", + "navigationBarTextStyle": "black" +} diff --git a/pages/eldercare-apply/eldercare-apply.wxml b/pages/eldercare-apply/eldercare-apply.wxml new file mode 100644 index 0000000..147a88b --- /dev/null +++ b/pages/eldercare-apply/eldercare-apply.wxml @@ -0,0 +1,191 @@ + + + + + + + + 返回 + + 智慧养老 + + + + + + + + + + + + + + + + + + 服务介绍 + + + 智慧养老服务为老年人提供专业的居家照护、健康管理、生活陪伴等服务,结合智能设备和专业护理,让老年人享受高品质的晚年生活。 + + + + + + + + + + 服务项目 + + + + 居家照护 + + + 健康监测 + + + 康复护理 + + + 生活陪伴 + + + 助餐服务 + + + 紧急救援 + + + + + + + + + + + 平台优势 + + + + + 专业护理团队,持证上岗 + + + + 智能设备辅助,实时监护 + + + + 24小时响应,紧急救援 + + + + 个性化服务,贴心关怀 + + + + + + + + 成为养老护理师,开启您的服务之旅 + + + + + + + + + + + + + {{statusTitle}} + {{statusDesc}} + + + + + + + + + 基本信息 + + + + + 姓名 + * + + + + + + + + + 所在城市 + (选填) + + + + + + + + + 手机号 + * + + + + + + + + + + + 备注信息 + + + + + + {{formData.remarks.length || 0}}/500 + + + + + + + + + + 我已阅读并同意 + 《合作入驻服务协议》 + + + + + + + + + + + diff --git a/pages/eldercare-apply/eldercare-apply.wxss b/pages/eldercare-apply/eldercare-apply.wxss new file mode 100644 index 0000000..d817662 --- /dev/null +++ b/pages/eldercare-apply/eldercare-apply.wxss @@ -0,0 +1,94 @@ +/* 智慧养老申请页面样式 */ +.page-container { + min-height: 100vh; + background: linear-gradient(180deg, #E8C3D4 0%, #F5E6EC 100%); +} + +.nav-header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + background: linear-gradient(135deg, #E8C3D4 0%, #D4A5C9 100%); +} + +.nav-content { + display: flex; + align-items: center; + justify-content: space-between; + height: 44px; + padding: 0 16px; +} + +.nav-back { display: flex; align-items: center; min-width: 60px; } +.back-icon { width: 20px; height: 20px; } +.back-text { font-size: 14px; color: #333; margin-left: 4px; } +.nav-title { font-size: 17px; font-weight: 600; color: #333; } +.nav-placeholder { min-width: 60px; } + +.content-scroll { height: 100vh; padding-bottom: env(safe-area-inset-bottom); } +.apply-form { padding: 16px; } +.status-card { background: #fff; border-radius: 16px; padding: 40px 24px; text-align: center; margin-bottom: 16px; } +.status-icon { width: 80px; height: 80px; margin: 0 auto 16px; } +.status-icon image { width: 100%; height: 100%; } +.status-title { display: block; font-size: 18px; font-weight: 600; color: #333; margin-bottom: 8px; } +.status-desc { font-size: 14px; color: #666; line-height: 1.6; } +.btn-secondary { margin-top: 24px; width: 160px; height: 44px; background: #fff; border: 1px solid #b06ab3; border-radius: 22px; color: #b06ab3; font-size: 15px; } + +.form-content { background: #fff; border-radius: 16px; padding: 24px 20px; } +.form-header { text-align: center; margin-bottom: 24px; } +.form-title { display: block; font-size: 20px; font-weight: 600; color: #333; margin-bottom: 8px; } +.form-subtitle { font-size: 14px; color: #999; } + +.form-section { margin-bottom: 24px; } +.section-header { display: flex; align-items: center; margin-bottom: 16px; } +.section-title { font-size: 16px; font-weight: 600; color: #333; } +.required { color: #ff4d4f; margin-left: 4px; } + +.avatar-upload-area { display: flex; justify-content: center; margin-bottom: 8px; } +.avatar-circle { width: 100px; height: 100px; border-radius: 50%; background: #f5f5f5; overflow: hidden; display: flex; align-items: center; justify-content: center; } +.avatar-image { width: 100%; height: 100%; } +.upload-placeholder { text-align: center; } +.camera-icon { width: 32px; height: 32px; margin-bottom: 4px; } +.upload-text { font-size: 12px; color: #999; } +.form-tip { font-size: 12px; color: #999; margin-top: 8px; } + +.form-item { margin-bottom: 16px; } +.item-label-row { display: flex; align-items: center; margin-bottom: 8px; } +.item-label { font-size: 14px; color: #333; } +.input-wrapper { background: #f8f8f8; border-radius: 8px; padding: 0 12px; } +.item-input { width: 100%; height: 44px; font-size: 14px; color: #333; } + +.gender-options { display: flex; gap: 12px; } +.gender-btn { flex: 1; height: 44px; background: #f8f8f8; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 14px; color: #666; } +.gender-btn.active { background: linear-gradient(135deg, #E8C3D4 0%, #D4A5C9 100%); color: #333; font-weight: 500; } + +.service-types { display: flex; flex-wrap: wrap; gap: 10px; } +.service-btn { padding: 8px 16px; background: #f8f8f8; border-radius: 20px; font-size: 13px; color: #666; } +.service-btn.active { background: linear-gradient(135deg, #E8C3D4 0%, #D4A5C9 100%); color: #333; } + +.level-options { display: flex; gap: 10px; } +.level-btn { flex: 1; padding: 10px 8px; background: #f8f8f8; border-radius: 8px; font-size: 13px; color: #666; text-align: center; } +.level-btn.active { background: linear-gradient(135deg, #E8C3D4 0%, #D4A5C9 100%); color: #333; font-weight: 500; } + +.cert-upload { width: 100%; height: 120px; background: #f8f8f8; border-radius: 8px; border: 1px dashed #ddd; display: flex; align-items: center; justify-content: center; overflow: hidden; } +.cert-image { width: 100%; height: 100%; } +.cert-placeholder { text-align: center; } +.upload-icon { width: 40px; height: 40px; margin-bottom: 8px; } + +.textarea-wrapper { background: #f8f8f8; border-radius: 8px; padding: 12px; } +.intro-textarea { width: 100%; height: 120px; font-size: 14px; color: #333; line-height: 1.6; } +.textarea-footer { display: flex; justify-content: flex-end; margin-top: 8px; } +.char-count { font-size: 12px; color: #999; } + +.agreement-row { display: flex; align-items: center; margin: 24px 0; } +.checkbox { width: 20px; height: 20px; border: 1px solid #ddd; border-radius: 4px; margin-right: 8px; display: flex; align-items: center; justify-content: center; } +.checkbox.checked { background: linear-gradient(135deg, #b06ab3 0%, #4568dc 100%); border-color: transparent; } +.check-icon { width: 14px; height: 14px; } +.normal-text { font-size: 13px; color: #666; } +.link-text { font-size: 13px; color: #b06ab3; } + +.submit-btn { width: 100%; height: 48px; background: linear-gradient(135deg, #b06ab3 0%, #4568dc 100%); border-radius: 24px; color: #fff; font-size: 16px; font-weight: 500; border: none; } +.submit-btn.disabled { opacity: 0.5; } +.bottom-placeholder { height: 40px; } diff --git a/pages/eldercare/eldercare.js b/pages/eldercare/eldercare.js new file mode 100644 index 0000000..7f5debf --- /dev/null +++ b/pages/eldercare/eldercare.js @@ -0,0 +1,46 @@ +const api = require('../../utils/api') + +Page({ + data: { + info: null, + loading: true, + error: null + }, + + onLoad() { + this.loadElderCareInfo() + }, + + onBack() { + wx.navigateBack() + }, + + async loadElderCareInfo() { + this.setData({ loading: true, error: null }) + + try { + const res = await api.common.getBrandConfig() + console.log('[eldercare] 智慧康养配置响应:', res) + + if (res.success && res.data) { + const data = res.data + const smartHealth = data.smart_health || {} + + this.setData({ + info: { + title: '智慧康养', + content: smartHealth.value || '' + } + }) + console.log('[eldercare] 智慧康养信息:', this.data.info) + } else { + this.setData({ error: res.error || '加载失败' }) + } + } catch (err) { + console.error('[eldercare] 加载失败:', err) + this.setData({ error: err.message || '加载失败' }) + } finally { + this.setData({ loading: false }) + } + } +}) diff --git a/pages/eldercare/eldercare.json b/pages/eldercare/eldercare.json new file mode 100644 index 0000000..8835af0 --- /dev/null +++ b/pages/eldercare/eldercare.json @@ -0,0 +1,3 @@ +{ + "usingComponents": {} +} \ No newline at end of file diff --git a/pages/eldercare/eldercare.wxml b/pages/eldercare/eldercare.wxml new file mode 100644 index 0000000..aae22f4 --- /dev/null +++ b/pages/eldercare/eldercare.wxml @@ -0,0 +1,38 @@ + + + + + + 返回 + + {{info.title}} + + + + + + + 加载中... + + + + + {{error}} + 点击重试 + + + + + + + + + + + + + + 暂无内容 + + + diff --git a/pages/eldercare/eldercare.wxss b/pages/eldercare/eldercare.wxss new file mode 100644 index 0000000..a738a16 --- /dev/null +++ b/pages/eldercare/eldercare.wxss @@ -0,0 +1,210 @@ +.page { + min-height: 100vh; + background-color: #f8f8f8; + display: flex; + flex-direction: column; +} + +.unified-header { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 194rpx; + background: linear-gradient(135deg, #B06AB3 0%, #9B59B6 100%); + border-bottom: none; + display: flex; + align-items: flex-end; + justify-content: space-between; + padding: 0 32rpx 20rpx; + z-index: 1000; +} + +.unified-header-left { + display: flex; + align-items: center; + gap: 8rpx; + width: 160rpx; + height: 56rpx; +} + +.unified-back-icon { + width: 56rpx; + height: 56rpx; +} + +.unified-back-text { + font-size: 34rpx; + font-weight: bold; + color: #ffffff; +} + +.unified-header-title { + font-size: 40rpx; + font-weight: bold; + color: #ffffff; + flex: 1; + text-align: center; +} + +.unified-header-right { + width: 160rpx; + height: 56rpx; +} + +.content { + flex: 1; +} + +.loading-tip, .error-tip, .empty-tip { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding-top: 200rpx; +} + +.loading-tip text, .empty-tip text, .error-tip text { + font-size: 28rpx; + color: #999; +} + +.error-tip text:first-child { + color: #ff6b6b; + margin-bottom: 20rpx; +} + +.retry-btn { + background: linear-gradient(135deg, #B06AB3 0%, #9B59B6 100%); + color: #fff !important; + padding: 16rpx 48rpx; + border-radius: 36rpx; + font-size: 28rpx !important; +} + +.empty-tip image { + width: 240rpx; + height: 240rpx; + margin-bottom: 20rpx; +} + +.eldercare-content { + padding: 30rpx; +} + +.intro-section { + background: #fff; + border-radius: 20rpx; + padding: 30rpx; + margin-bottom: 30rpx; +} + +.intro-section rich-text { + font-size: 28rpx; + color: #666; + line-height: 1.8; +} + +.section-title { + font-size: 32rpx; + font-weight: bold; + color: #333; + margin-bottom: 20rpx; + padding-bottom: 16rpx; + border-bottom: 2rpx solid #f0f0f0; +} + +.services-section { + background: #fff; + border-radius: 20rpx; + padding: 30rpx; + margin-bottom: 30rpx; +} + +.services-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20rpx; + margin-top: 20rpx; +} + +.service-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 30rpx 20rpx; + background: #f8f8f8; + border-radius: 16rpx; +} + +.service-icon { + width: 80rpx; + height: 80rpx; + background: linear-gradient(135deg, #B06AB3 0%, #9B59B6 100%); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 16rpx; +} + +.service-name { + font-size: 26rpx; + color: #333; + text-align: center; +} + +.articles-section { + background: #fff; + border-radius: 20rpx; + padding: 30rpx; +} + +.article-list { + display: flex; + flex-direction: column; + gap: 20rpx; + margin-top: 20rpx; +} + +.article-item { + display: flex; + padding: 20rpx; + background: #f8f8f8; + border-radius: 12rpx; +} + +.article-cover { + width: 180rpx; + height: 120rpx; + border-radius: 8rpx; + margin-right: 20rpx; + flex-shrink: 0; +} + +.article-info { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; +} + +.article-title { + font-size: 28rpx; + font-weight: bold; + color: #333; + margin-bottom: 8rpx; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + overflow: hidden; +} + +.article-desc { + font-size: 24rpx; + color: #666; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; +} diff --git a/pages/entertainment-apply/entertainment-apply.js b/pages/entertainment-apply/entertainment-apply.js new file mode 100644 index 0000000..4a3ab3d --- /dev/null +++ b/pages/entertainment-apply/entertainment-apply.js @@ -0,0 +1,162 @@ +// pages/entertainment-apply/entertainment-apply.js +// 休闲文娱申请页面 +const api = require('../../utils/api') + +Page({ + data: { + statusBarHeight: 44, + navBarHeight: 44, + totalNavHeight: 88, + showForm: true, + applyStatus: 'none', + statusTitle: '', + statusDesc: '', + isReapply: false, + agreed: false, + formData: { + realName: '', + city: '', + phone: '', + remarks: '' + }, + canSubmit: false + }, + + onLoad(options) { + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + const menuButton = wx.getMenuButtonBoundingClientRect() + const navBarHeight = menuButton.height + (menuButton.top - statusBarHeight) * 2 + const totalNavHeight = statusBarHeight + navBarHeight + + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight, + isReapply: options.isReapply === 'true' + }) + + this.checkApplyStatus() + }, + + goBack() { + wx.navigateBack() + }, + + async checkApplyStatus() { + const token = wx.getStorageSync('auth_token') + if (!token) { + this.setData({ applyStatus: 'none' }) + return + } + + try { + const res = await api.request('/entertainment/apply') + if (res.success && res.data) { + const data = res.data + if (data.status === 'approved') { + this.setData({ + applyStatus: 'approved', + statusTitle: '申请已通过', + statusDesc: '恭喜您成为休闲文娱服务师!' + }) + } else if (data.status === 'pending') { + this.setData({ + applyStatus: 'pending', + statusTitle: '审核中', + statusDesc: '您的申请正在审核中,请耐心等待' + }) + } else if (data.status === 'rejected') { + this.setData({ + applyStatus: 'rejected', + statusTitle: '申请未通过', + statusDesc: data.rejectReason || '很抱歉,您的申请未通过审核' + }) + } + } + } catch (err) { + console.log('获取申请状态失败:', err) + this.setData({ applyStatus: 'none' }) + } + }, + + reapply() { + this.setData({ isReapply: true, applyStatus: 'none' }) + }, + + onInputChange(e) { + const field = e.currentTarget.dataset.field + this.setData({ [`formData.${field}`]: e.detail.value }) + this.checkCanSubmit() + }, + + toggleAgreement() { + this.setData({ agreed: !this.data.agreed }) + this.checkCanSubmit() + }, + + viewAgreement() { + wx.navigateTo({ url: '/pages/agreement/agreement?code=cooperation_service' }) + }, + + checkCanSubmit() { + const { formData, agreed } = this.data + + const canSubmit = + formData.realName && + formData.phone && + formData.phone.length === 11 && + agreed + + this.setData({ canSubmit }) + }, + + async submitApply() { + if (!this.data.canSubmit) return + + const { formData } = this.data + + if (!/^1[3-9]\d{9}$/.test(formData.phone)) { + wx.showToast({ title: '请输入正确的手机号', icon: 'none' }) + return + } + + wx.showLoading({ title: '提交中...' }) + try { + const res = await api.request('/entertainment/apply', { + method: 'POST', + data: { + realName: formData.realName, + city: formData.city, + phone: formData.phone, + remarks: formData.remarks + } + }) + + if (res.success || res.code === 0) { + wx.showToast({ title: '申请已提交', icon: 'success' }) + this.setData({ + applyStatus: 'pending', + statusTitle: '审核中', + statusDesc: '您的申请正在审核中,请耐心等待', + isReapply: false + }) + } else { + wx.showToast({ title: res.message || '提交失败', icon: 'none' }) + } + } catch (err) { + if (err.code === 404) { + wx.showModal({ + title: '提示', + content: '该服务申请即将开放,敬请期待!', + showCancel: false, + confirmColor: '#b06ab3' + }) + } else { + wx.showToast({ title: err.message || '提交失败', icon: 'none' }) + } + } finally { + wx.hideLoading() + } + } +}) diff --git a/pages/entertainment-apply/entertainment-apply.json b/pages/entertainment-apply/entertainment-apply.json new file mode 100644 index 0000000..a3e86ce --- /dev/null +++ b/pages/entertainment-apply/entertainment-apply.json @@ -0,0 +1,5 @@ +{ + "usingComponents": {}, + "navigationStyle": "custom", + "navigationBarTextStyle": "black" +} diff --git a/pages/entertainment-apply/entertainment-apply.wxml b/pages/entertainment-apply/entertainment-apply.wxml new file mode 100644 index 0000000..bf9ec6f --- /dev/null +++ b/pages/entertainment-apply/entertainment-apply.wxml @@ -0,0 +1,103 @@ + + + + + + + + 返回 + + 休闲文娱 + + + + + + + + + + + + + + + + {{statusTitle}} + {{statusDesc}} + + + + + + + + + 基本信息 + + + + + 姓名 + * + + + + + + + + + 所在城市 + (选填) + + + + + + + + + 手机号 + * + + + + + + + + + + + 备注信息 + + + + + + {{formData.remarks.length || 0}}/500 + + + + + + + + + + 我已阅读并同意 + 《合作入驻服务协议》 + + + + + + + + + + + diff --git a/pages/entertainment-apply/entertainment-apply.wxss b/pages/entertainment-apply/entertainment-apply.wxss new file mode 100644 index 0000000..9b3292e --- /dev/null +++ b/pages/entertainment-apply/entertainment-apply.wxss @@ -0,0 +1,169 @@ +/* 休闲文娱申请页面样式 - 玫瑰紫版 v3.0 */ +.page-container { + min-height: 100vh; + background: linear-gradient(180deg, #E8C3D4 0%, #F5E6ED 100%); +} + +.nav-header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + background: rgba(248, 249, 252, 0.75); + backdrop-filter: blur(20rpx) saturate(180%); +} + +.nav-content { + display: flex; + align-items: center; + justify-content: space-between; + height: 44px; + padding: 0 16px; +} + +.nav-back { display: flex; align-items: center; min-width: 60px; } +.back-icon { width: 20px; height: 20px; } +.back-text { font-size: 14px; color: #111827; margin-left: 4px; } +.nav-title { font-size: 17px; font-weight: 600; color: #111827; } +.nav-placeholder { min-width: 60px; } + +.content-scroll { height: 100vh; padding-bottom: env(safe-area-inset-bottom); } +.intro-section { padding: 16px; } + +.banner-card { + height: 160px; + border-radius: 16px; + overflow: hidden; + margin-bottom: 16px; + background: linear-gradient(135deg, #914584 0%, #7A3A6F 100%); + box-shadow: 0 4rpx 16rpx rgba(145, 69, 132, 0.25); +} + +.banner-gradient { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.banner-title { font-size: 24px; font-weight: 600; color: #fff; margin-bottom: 8px; } +.banner-subtitle { font-size: 14px; color: rgba(255,255,255,0.9); } + +.info-card { + background: #fff; + border-radius: 16px; + padding: 20px; + margin-bottom: 16px; + box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06), + 0 2rpx 8rpx rgba(0, 0, 0, 0.04); + transition: transform 0.25s ease, box-shadow 0.25s ease; +} + +.card-header { display: flex; align-items: center; margin-bottom: 16px; } + +.card-icon { + width: 32px; + height: 32px; + background: linear-gradient(135deg, #914584 0%, #7A3A6F 100%); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + margin-right: 12px; + box-shadow: 0 2rpx 8rpx rgba(145, 69, 132, 0.2); +} + +.card-icon image { width: 20px; height: 20px; } +.card-title { font-size: 18px; font-weight: 600; color: #111827; } +.intro-text { font-size: 14px; color: #6B7280; line-height: 1.8; } + +.service-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; } +.service-item { background: #f8f8f8; border-radius: 8px; padding: 12px 8px; text-align: center; transition: transform 0.2s ease; } +.service-item:active { transform: scale(0.95); } +.service-name { font-size: 13px; color: #111827; } + +.advantage-list { padding: 0; } +.advantage-item { display: flex; align-items: center; padding: 8px 0; } +.advantage-dot { width: 6px; height: 6px; background: linear-gradient(135deg, #914584 0%, #7A3A6F 100%); border-radius: 50%; margin-right: 12px; } +.advantage-text { font-size: 14px; color: #6B7280; } + +.apply-btn-area { padding: 24px 0; text-align: center; } +.apply-btn { width: 100%; height: 48px; background: linear-gradient(135deg, #914584 0%, #7A3A6F 100%); border-radius: 24px; color: #fff; font-size: 16px; font-weight: 500; border: none; margin-bottom: 12px; box-shadow: 0 4rpx 16rpx rgba(145, 69, 132, 0.35); transition: all 0.25s ease; } +.apply-btn:active { transform: scale(0.98); box-shadow: 0 2rpx 8rpx rgba(145, 69, 132, 0.4); } +.apply-tip { font-size: 13px; color: #9CA3AF; } + +.apply-form { padding: 16px; } +.status-card { background: #fff; border-radius: 16px; padding: 40px 24px; text-align: center; margin-bottom: 16px; } +.status-icon { width: 80px; height: 80px; margin: 0 auto 16px; } +.status-icon image { width: 100%; height: 100%; } +.status-title { display: block; font-size: 18px; font-weight: 600; color: #111827; margin-bottom: 8px; } +.status-desc { font-size: 14px; color: #6B7280; line-height: 1.6; } +.btn-secondary { margin-top: 24px; width: 160px; height: 44px; background: #fff; border: 1px solid #914584; border-radius: 22px; color: #914584; font-size: 15px; transition: all 0.2s ease; } +.btn-secondary:active { transform: scale(0.95); background: #F5E6ED; } + +.form-content { background: #fff; border-radius: 16px; padding: 24px 20px; box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06), + 0 2rpx 8rpx rgba(0, 0, 0, 0.04); } +.form-header { text-align: center; margin-bottom: 24px; } +.form-title { display: block; font-size: 20px; font-weight: 600; color: #111827; margin-bottom: 8px; } +.form-subtitle { font-size: 14px; color: #9CA3AF; } + +.form-section { margin-bottom: 24px; } +.section-header { display: flex; align-items: center; margin-bottom: 16px; } +.section-title { font-size: 16px; font-weight: 600; color: #111827; } +.required { color: #ff4d4f; margin-left: 4px; } + +.avatar-upload-area { display: flex; justify-content: center; margin-bottom: 8px; } +.avatar-circle { width: 100px; height: 100px; border-radius: 50%; background: linear-gradient(135deg, #E5E7EB 0%, #F3F4F6 100%); overflow: hidden; display: flex; align-items: center; justify-content: center; border: 2px solid #F1F5F9; } +.avatar-image { width: 100%; height: 100%; } +.upload-placeholder { text-align: center; } +.camera-icon { width: 32px; height: 32px; margin-bottom: 4px; } +.upload-text { font-size: 12px; color: #9CA3AF; } +.form-tip { font-size: 12px; color: #9CA3AF; margin-top: 8px; } + +.form-item { margin-bottom: 16px; } +.item-label-row { display: flex; align-items: center; margin-bottom: 8px; } +.item-label { font-size: 14px; color: #111827; } +.input-wrapper { background: #f8f8f8; border-radius: 8px; padding: 0 12px; transition: background 0.2s ease; } +.input-wrapper:focus-within { background: #F3F4F6; } +.item-input { width: 100%; height: 44px; font-size: 14px; color: #111827; } + +.gender-options { display: flex; gap: 12px; } +.gender-btn { flex: 1; height: 44px; background: #f8f8f8; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 14px; color: #6B7280; transition: all 0.2s ease; } +.gender-btn:active { transform: scale(0.95); } +.gender-btn.active { background: linear-gradient(135deg, #914584 0%, #7A3A6F 100%); color: #fff; font-weight: 500; box-shadow: 0 2rpx 8rpx rgba(145, 69, 132, 0.3); } + +.service-types { display: flex; flex-wrap: wrap; gap: 10px; } +.service-btn { padding: 8px 16px; background: #f8f8f8; border-radius: 20px; font-size: 13px; color: #6B7280; transition: all 0.2s ease; } +.service-btn:active { transform: scale(0.95); } +.service-btn.active { background: linear-gradient(135deg, #914584 0%, #7A3A6F 100%); color: #fff; box-shadow: 0 2rpx 8rpx rgba(145, 69, 132, 0.3); } + +.level-options { display: flex; gap: 10px; } +.level-btn { flex: 1; padding: 10px 8px; background: #f8f8f8; border-radius: 8px; font-size: 13px; color: #6B7280; text-align: center; transition: all 0.2s ease; } +.level-btn:active { transform: scale(0.95); } +.level-btn.active { background: linear-gradient(135deg, #914584 0%, #7A3A6F 100%); color: #fff; font-weight: 500; box-shadow: 0 2rpx 8rpx rgba(145, 69, 132, 0.3); } + +.cert-upload { width: 100%; height: 120px; background: linear-gradient(135deg, #E5E7EB 0%, #F3F4F6 100%); border-radius: 8px; border: 1px dashed #D1D5DB; display: flex; align-items: center; justify-content: center; overflow: hidden; transition: border-color 0.2s ease; } +.cert-upload:active { border-color: #914584; } +.cert-image { width: 100%; height: 100%; } +.cert-placeholder { text-align: center; } +.upload-icon { width: 40px; height: 40px; margin-bottom: 8px; } + +.textarea-wrapper { background: #f8f8f8; border-radius: 8px; padding: 12px; } +.intro-textarea { width: 100%; height: 120px; font-size: 14px; color: #111827; line-height: 1.6; } +.textarea-footer { display: flex; justify-content: flex-end; margin-top: 8px; } +.char-count { font-size: 12px; color: #9CA3AF; } + +.agreement-row { display: flex; align-items: center; margin: 24px 0; } +.checkbox { width: 20px; height: 20px; border: 1px solid #D1D5DB; border-radius: 4px; margin-right: 8px; display: flex; align-items: center; justify-content: center; transition: all 0.2s ease; } +.checkbox.checked { background: linear-gradient(135deg, #914584 0%, #7A3A6F 100%); border-color: transparent; box-shadow: 0 2rpx 8rpx rgba(145, 69, 132, 0.3); } +.check-icon { width: 14px; height: 14px; } +.normal-text { font-size: 13px; color: #6B7280; } +.link-text { font-size: 13px; color: #914584; } + +.submit-btn { width: 100%; height: 48px; background: linear-gradient(135deg, #914584 0%, #7A3A6F 100%); border-radius: 24px; color: #fff; font-size: 16px; font-weight: 500; border: none; box-shadow: 0 4rpx 16rpx rgba(145, 69, 132, 0.35); transition: all 0.25s ease; } +.submit-btn:active { transform: scale(0.98); box-shadow: 0 2rpx 8rpx rgba(145, 69, 132, 0.4); } +.submit-btn.disabled { opacity: 0.5; transform: none; } +.bottom-placeholder { height: 40px; } diff --git a/pages/entertainment/entertainment.js b/pages/entertainment/entertainment.js new file mode 100644 index 0000000..a4c666c --- /dev/null +++ b/pages/entertainment/entertainment.js @@ -0,0 +1,894 @@ +// pages/entertainment/entertainment.js - 休闲文娱页面 +// 根据Figma设计实现 + +const api = require('../../utils/api') +const app = getApp() + +Page({ + data: { + statusBarHeight: 44, + navBarHeight: 44, + totalNavHeight: 88, + loading: false, + loadingMore: false, + + // 顶部轮播数据 - 从后台素材管理API加载 + bannerList: [], + swiperHeight: 400, + currentBannerIndex: 0, + + // 功能入口 - 使用正式环境图片URL + categoryList: [ + { + id: 1, + name: '兴趣搭子', + icon: '/images/icon-interest.png' + }, + { + id: 2, + name: '同城活动', + icon: '/images/icon-city.png' + }, + { + id: 3, + name: '户外郊游', + icon: '/images/icon-outdoor.png' + }, + { + id: 4, + name: '高端定制', + icon: '/images/icon-travel.png' + }, + { + id: 5, + name: '快乐学堂', + icon: '/images/icon-checkin.png' + }, + { + id: 6, + name: '单身聚会', + icon: '/images/icon-love.png' + } + ], + + // 滚动公告 + noticeList: [], + currentNoticeIndex: 0, + + // 活动标签 + activeTab: 'featured', // featured: 精选活动, free: 免费活动, vip: VIP活动, svip: SVIP活动 + + // 活动列表 + activityList: [], + + // 分页相关 + page: 1, + limit: 20, + hasMore: true, + total: 0, + + // 二维码引导弹窗 + showQrcodeModal: false, + qrcodeImageUrl: '', + + // 未读消息数 + totalUnread: 0, + + // 审核状态 + auditStatus: 0 + }, + + onLoad() { + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + const menuButton = wx.getMenuButtonBoundingClientRect() + const navBarHeight = menuButton.height + (menuButton.top - statusBarHeight) * 2 + const totalNavHeight = statusBarHeight + navBarHeight + + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight + }) + + this.loadPageData() + this.loadNotices() + this.startNoticeScroll() + }, + + /** + * 加载页面数据 + */ + async loadPageData() { + // 并行加载Banner、功能入口和活动列表 + await Promise.all([ + this.loadBanners(), + this.loadEntries(), + this.loadActivityList() + ]) + }, + + /** + * 处理图片URL,如果是相对路径则拼接域名,并设置清晰度为85 + */ + processImageUrl(url) { + if (!url) return '' + let fullUrl = url + if (!url.startsWith('http://') && !url.startsWith('https://')) { + const baseUrl = 'https://ai-c.maimanji.com' + fullUrl = baseUrl + (url.startsWith('/') ? '' : '/') + url + } + + // 添加清晰度参数 q=85 + if (fullUrl.includes('?')) { + if (!fullUrl.includes('q=')) { + fullUrl += '&q=85' + } + } else { + fullUrl += '?q=85' + } + return fullUrl + }, + + /** + * 轮播图图片加载完成,自适应高度 + */ + onBannerLoad(e) { + if (this.data.swiperHeight !== 300) return; // 只计算一次 + const { width, height } = e.detail; + const sysInfo = wx.getSystemInfoSync(); + // 减去左右padding (32rpx * 2) + const swiperWidth = sysInfo.windowWidth - (32 * 2 / 750 * sysInfo.windowWidth); + const ratio = width / height; + const swiperHeight = swiperWidth / ratio; + const swiperHeightRpx = swiperHeight * (750 / sysInfo.windowWidth); + + this.setData({ + swiperHeight: swiperHeightRpx + }); + }, + + /** + * 加载功能入口图标 + * 从后台素材管理API加载 (group=entries) + */ + async loadEntries() { + try { + const res = await api.pageAssets.getAssets('entries') + console.log('功能入口 API响应:', res) + + if (res.success && res.data) { + const icons = res.data + const { categoryList } = this.data + + // 映射图标:搭子(id=1), 同城(id=2), 户外(id=3), 定制(id=4), 学堂(id=5), 传递(id=6) + const idMap = { + 1: 'entry_1', // 兴趣搭子 + 2: 'entry_2', // 同城活动 + 3: 'entry_3', // 户外郊游 + 4: 'entry_4', // 定制主题 + 5: 'entry_5', // 快乐学堂 + 6: 'entry_6' // 爱心传递 + } + + const updatedCategoryList = categoryList.map(item => { + const assetKey = idMap[item.id] + const iconUrl = icons[assetKey] + if (iconUrl) { + return { + ...item, + icon: this.processImageUrl(iconUrl) + } + } + return item + }) + + this.setData({ categoryList: updatedCategoryList }) + console.log('已更新娱乐页功能入口图标') + } + } catch (err) { + console.error('加载功能入口失败', err) + } + }, + + /** + * 加载娱乐页Banner + * 调用专用API:/api/page-assets/entertainment-banners + */ + async loadBanners() { + try { + const res = await api.pageAssets.getEntertainmentBanners() + + console.log('娱乐页Banner API响应:', res) + + if (res.success && res.data) { + // 处理相对路径,拼接完整URL + // 兼容不同可能的字段名:asset_url, url, imageUrl + const bannerList = res.data.map(item => { + const rawUrl = item.asset_url || item.url || item.imageUrl || item.image_url || '' + return { + id: item.id, + imageUrl: this.processImageUrl(rawUrl), + // 只保留标签,不显示标题和副标题 + tag: item.description || item.tag || '热门推荐', + title: '', + subtitle: '', + bgColor: item.bg_color || item.bgColor || 'linear-gradient(135deg, #E8D5F0 0%, #F5E6D3 100%)' + } + }).filter(item => item.imageUrl) // 只保留有图片的 + + if (bannerList.length > 0) { + this.setData({ bannerList }) + console.log(`加载了 ${bannerList.length} 个娱乐页Banner`) + } else { + console.log('娱乐页Banner数据为空或解析失败,使用默认配置') + this.setDefaultBanners() + } + } else { + console.log('娱乐页Banner API返回失败,使用默认配置') + this.setDefaultBanners() + } + } catch (err) { + console.error('加载娱乐页Banner失败', err) + this.setDefaultBanners() + } + }, + + /** + * 设置默认Banner(降级方案 - 使用CDN URL) + */ + setDefaultBanners() { + const cdnBase = 'https://ai-c.maimanji.com/images' + this.setData({ + bannerList: [ + { + id: 1, + imageUrl: `${cdnBase}/service-banner-1.png`, + tag: '热门', + title: '', + subtitle: '', + bgColor: 'linear-gradient(135deg, #E8D5F0 0%, #F5E6D3 100%)' + }, + { + id: 2, + imageUrl: `${cdnBase}/service-banner-2.png`, + tag: '活动', + title: '', + subtitle: '', + bgColor: 'linear-gradient(135deg, #D5E8F0 0%, #E6F5D3 100%)' + }, + { + id: 3, + imageUrl: `${cdnBase}/service-banner-3.png`, + tag: '推荐', + title: '', + subtitle: '', + bgColor: 'linear-gradient(135deg, #F0E8D5 0%, #F5D3E6 100%)' + } + ] + }) + console.log('使用默认娱乐页Banner配置') + }, + + /** + * 加载公告 + */ + async loadNotices() { + try { + const res = await api.common.getNotices() + console.log('[notice] 公告API响应:', res) + + if (res.success && res.data && res.data.length > 0) { + const noticeList = res.data.map(item => ({ + id: item.id, + content: item.content, + linkType: item.linkType || 'none', + linkValue: item.linkValue || '' + })) + this.setData({ noticeList }) + } + } catch (err) { + console.error('[notice] 加载公告失败', err) + } + }, + + /** + * 点击公告栏 + */ + onNoticeTap() { + wx.navigateTo({ + url: '/pages/notices/notices' + }) + }, + + onShow() { + if (typeof this.getTabBar === 'function' && this.getTabBar()) { + this.getTabBar().setData({ selected: 1 }) + } + wx.hideTabBar({ animation: false }) + const app = getApp() + this.setData({ + auditStatus: app.globalData.auditStatus + }) + this.loadUnreadCount() + }, + + onUnload() { + if (this.noticeTimer) { + clearInterval(this.noticeTimer) + } + }, + + + /** + * 开始公告滚动 + */ + startNoticeScroll() { + this.noticeTimer = setInterval(() => { + const { noticeList, currentNoticeIndex } = this.data + const nextIndex = (currentNoticeIndex + 1) % noticeList.length + this.setData({ currentNoticeIndex: nextIndex }) + }, 3000) + }, + + /** + * 加载活动列表 - 根据activeTab加载不同的活动(支持分页) + */ + async loadActivityList(isLoadMore = false) { + console.log('========== 加载活动列表 ==========') + console.log('[6] activeTab:', this.data.activeTab) + console.log('[6.1] isLoadMore:', isLoadMore) + + if (isLoadMore) { + this.setData({ loadingMore: true }) + } else { + this.setData({ loading: true, page: 1, hasMore: true }) + } + + try { + const config = require('../../config/index') + const { activeTab, page, limit } = this.data + + console.log('[8] 请求URL:', `${config.API_BASE_URL}/entertainment/home`) + console.log('[9] 请求参数:', { type: activeTab, page, limit }) + + const res = await new Promise((resolve, reject) => { + wx.request({ + url: `${config.API_BASE_URL}/entertainment/home`, + method: 'GET', + data: { + type: activeTab, + page, + limit + }, + timeout: 10000, + success: (res) => resolve(res), + fail: (err) => reject(err) + }) + }) + + console.log('[10] API响应状态:', res.statusCode) + console.log('[11] API响应数据:', JSON.stringify(res.data, null, 2)) + + if (res.statusCode === 200 && res.data.success && res.data.data) { + const homeData = res.data.data + const total = res.data.data.total || 0 + + let activities = [] + if (activeTab === 'featured') { + activities = homeData.featuredActivities || [] + console.log('[12] 精选活动原始数量:', activities.length) + } else if (activeTab === 'free') { + activities = homeData.freeActivities || [] + console.log('[12] 免费活动原始数量:', activities.length) + } else if (activeTab === 'vip') { + activities = homeData.vipActivities || [] + console.log('[12] VIP活动原始数量:', activities.length) + } else if (activeTab === 'svip') { + activities = homeData.svipActivities || [] + console.log('[12] SVIP活动原始数量:', activities.length) + } + + const newActivityList = activities.map(item => { + const heat = item.heat !== undefined && item.heat !== null + ? item.heat + : (item.likesCount || 0) * 2 + (item.viewsCount || 0) + ((item.virtualParticipants || 0) + (item.currentParticipants || 0)) * 3 + + return { + id: item.id, + title: item.title, + date: this.formatDate(item.activityDate), + location: item.location || '', + venue: item.venue || '', + image: item.coverImage || '/images/activity-default.jpg', + bgColor: this.getRandomGradient(), + price: item.priceText || '免费', + priceType: item.priceType || 'free', + likes: item.likesCount || 0, + participants: item.currentParticipants || 0, + maxParticipants: item.maxParticipants || 0, + isLiked: item.isLiked || false, + isSignedUp: item.isSignedUp || false, + signupEnabled: item.signupEnabled !== undefined ? item.signupEnabled : true, + activityGuideQrcode: item.activityGuideQrcode || '', + categoryName: item.categoryName || '', + heat: Math.floor(heat), + participantAvatars: item.participantAvatars || [ + 'https://i.pravatar.cc/100?u=1', + 'https://i.pravatar.cc/100?u=2', + 'https://i.pravatar.cc/100?u=3' + ] + } + }) + + console.log('[13] 转换后活动数量:', newActivityList.length) + + const hasMore = activities.length >= limit && (this.data.activityList.length + activities.length) < total + console.log('[14] hasMore:', hasMore, 'current:', this.data.activityList.length + activities.length, 'total:', total) + + if (isLoadMore) { + this.setData({ + activityList: [...this.data.activityList, ...newActivityList], + loadingMore: false, + hasMore, + page: this.data.page + 1, + total + }) + } else { + this.setData({ + activityList: newActivityList, + hasMore, + total + }) + } + console.log('[15] setData完成,当前页面活动数量:', this.data.activityList.length) + } else { + console.log('[ERROR] API返回失败') + if (isLoadMore) { + this.setData({ loadingMore: false }) + } else { + this.setData({ activityList: [], hasMore: false }) + } + } + } catch (err) { + console.error('[ERROR] 加载活动列表失败:', err) + if (isLoadMore) { + this.setData({ loadingMore: false }) + } else { + this.setData({ activityList: [], loading: false, hasMore: false }) + } + } finally { + if (!isLoadMore) { + this.setData({ loading: false }) + } + console.log('========== 加载完成 ==========') + } + }, + + /** + * 加载模拟数据(降级方案) + */ + loadMockActivities() { + // 使用空数据,等待后端API返回真实数据 + const mockActivities = [] + + this.setData({ activityList: mockActivities }) + }, + + /** + * 格式化日期 + */ + formatDate(dateStr) { + if (!dateStr) return '' + const date = new Date(dateStr) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}年${month}月${day}日` + }, + + /** + * 获取随机渐变色 + */ + getRandomGradient() { + const gradients = [ + 'linear-gradient(135deg, #E8D5F0 0%, #F5E6D3 100%)', + 'linear-gradient(135deg, #D5F0E8 0%, #E6D3F5 100%)', + 'linear-gradient(135deg, #F0D5E8 0%, #D3F5E6 100%)', + 'linear-gradient(135deg, #F5E6D3 0%, #E8D5F0 100%)' + ] + return gradients[Math.floor(Math.random() * gradients.length)] + }, + + /** + * 加载未读消息数 + */ + async loadUnreadCount() { + if (!app.globalData.isLoggedIn) { + this.setData({ totalUnread: 0 }) + return + } + + try { + const res = await api.chat.getConversations() + if (res.success && res.data) { + const totalUnread = res.data.reduce((sum, conv) => sum + (conv.unread_count || 0), 0) + this.setData({ totalUnread }) + } + } catch (err) { + console.log('获取未读消息数失败', err) + } + }, + + /** + * 轮播图切换 + */ + onBannerChange(e) { + // 只在用户手动滑动或自动播放时更新索引 + if (e.detail.source === 'autoplay' || e.detail.source === 'touch') { + this.setData({ currentBannerIndex: e.detail.current }) + } + }, + + /** + * 轮播指示器点击 + */ + onDotTap(e) { + const index = e.currentTarget.dataset.index + // 避免重复设置相同索引 + if (index !== this.data.currentBannerIndex) { + this.setData({ currentBannerIndex: index }) + } + }, + + /** + * 分类点击 + */ + onCategoryTap(e) { + const { id, name } = e.currentTarget.dataset + + // 兴趣搭子跳转到专门页面 + if (id === 1) { + wx.navigateTo({ + url: '/pages/interest-partner/interest-partner' + }) + return + } + + // 同城活动跳转到专门页面 + if (id === 2) { + wx.navigateTo({ + url: '/pages/city-activities/city-activities' + }) + return + } + + // 户外郊游跳转到专门页面 + if (id === 3) { + wx.navigateTo({ + url: '/pages/outdoor-activities/outdoor-activities' + }) + return + } + + // 定制主题跳转到专门页面 + if (id === 4) { + wx.navigateTo({ + url: '/pages/theme-travel/theme-travel' + }) + return + } + + // 快乐学堂跳转到专门页面 + if (id === 5) { + wx.navigateTo({ + url: '/pages/happy-school/happy-school' + }) + return + } + + // 单身聚会跳转到专门页面 + if (id === 6) { + wx.navigateTo({ + url: '/pages/singles-party/singles-party' + }) + return + } + + wx.showToast({ title: `${name}功能开发中`, icon: 'none' }) + // TODO: 跳转到对应分类页面 + }, + + /** + * 切换活动标签 + */ + onTabChange(e) { + const tab = e.currentTarget.dataset.tab + console.log('========== Tab切换开始 ==========') + console.log('[1] 点击的Tab:', tab) + console.log('[2] 当前activeTab:', this.data.activeTab) + + if (tab === this.data.activeTab) { + console.log('[3] Tab未变化,跳过') + return + } + + console.log('[4] 更新activeTab为:', tab) + this.setData({ + activeTab: tab, + activityList: [], + page: 1, + hasMore: true + }) + console.log('[5] 调用loadActivityList()') + this.loadActivityList() + }, + + /** + * 下拉刷新 + */ + onPullDownRefresh() { + this.loadActivityList(false).finally(() => { + wx.stopPullDownRefresh() + }) + }, + + /** + * 上拉加载更多 + */ + onReachBottom() { + if (this.data.hasMore && !this.data.loadingMore && !this.data.loading) { + console.log('[100] 触发上拉加载更多') + this.loadActivityList(true) + } else { + console.log('[101] 不满足加载条件:', { + hasMore: this.data.hasMore, + loadingMore: this.data.loadingMore, + loading: this.data.loading + }) + } + }, + + /** + * 活动卡片点击 + */ + onActivityTap(e) { + const id = e.currentTarget.dataset.id + wx.navigateTo({ + url: `/pages/activity-detail/activity-detail?id=${id}` + }) + }, + + /** + * 报名按钮点击 + */ + async onSignUp(e) { + const id = e.currentTarget.dataset.id + const index = e.currentTarget.dataset.index + + if (!app.globalData.isLoggedIn) { + wx.navigateTo({ url: '/pages/login/login' }) + return + } + + const activity = this.data.activityList[index] + + // 检查活动状态:满员或结束时弹出二维码 + const isFull = activity.participants >= activity.maxParticipants && activity.maxParticipants > 0 + const isEnded = activity.status === 'ended' || (activity.endDate && new Date(activity.endDate) < new Date()) + + if (isFull || isEnded) { + const qrCode = activity.activityGuideQrcode || activity.activity_guide_qrcode || this.data.qrcodeImageUrl || 'https://ai-c.maimanji.com/api/common/qrcode?type=group' + this.setData({ + qrcodeImageUrl: qrCode, + showQrcodeModal: true + }) + return + } + + // 如果报名功能已关闭,直接显示二维码 + if (activity.signupEnabled === false) { + if (activity.activityGuideQrcode) { + this.setData({ qrcodeImageUrl: activity.activityGuideQrcode }) + } + this.setData({ showQrcodeModal: true }) + return + } + + try { + if (activity.isSignedUp) { + // 取消报名 + const res = await api.activity.cancelSignup(id) + if (res.success) { + wx.showToast({ title: '已取消报名', icon: 'success' }) + this.setData({ + [`activityList[${index}].isSignedUp`]: false, + [`activityList[${index}].participants`]: res.data.currentParticipants + }) + } + } else { + // 报名 + const userInfo = app.globalData.userInfo || {} + const res = await api.activity.signup(id, { + remark: userInfo.nickname || '', + contactPhone: userInfo.phone || '' + }) + if (res.success) { + wx.showToast({ title: '报名成功', icon: 'success' }) + this.setData({ + [`activityList[${index}].isSignedUp`]: true, + [`activityList[${index}].participants`]: res.data.currentParticipants + }) + } else { + // 检查是否需要显示二维码(后端开关关闭) + if (res.code === 'QR_CODE_REQUIRED') { + if (activity.activityGuideQrcode) { + this.setData({ qrcodeImageUrl: activity.activityGuideQrcode }) + } + this.setData({ showQrcodeModal: true }) + } else if (res.code === 'ACTIVITY_ENDED' || res.error === '活动已结束') { + if (activity.activityGuideQrcode) { + this.setData({ qrcodeImageUrl: activity.activityGuideQrcode }) + } + this.setData({ showQrcodeModal: true }) + wx.showToast({ title: '活动已结束,进群查看更多', icon: 'none' }) + } else { + wx.showToast({ + title: res.error || '报名失败', + icon: 'none' + }) + } + } + } + } catch (err) { + console.error('报名操作失败', err) + const isQrRequired = err && (err.code === 'QR_CODE_REQUIRED' || (err.data && err.data.code === 'QR_CODE_REQUIRED')) + const isActivityEnded = err && (err.code === 'ACTIVITY_ENDED' || (err.data && err.data.code === 'ACTIVITY_ENDED') || err.error === '活动已结束') + + if (isQrRequired || isActivityEnded) { + if (activity.activityGuideQrcode) { + this.setData({ qrcodeImageUrl: activity.activityGuideQrcode }) + } + this.setData({ showQrcodeModal: true }) + if (isActivityEnded) { + wx.showToast({ title: '活动已结束,进群查看更多', icon: 'none' }) + } + } else { + wx.showToast({ + title: err.error || err.message || '操作失败', + icon: 'none' + }) + } + } + }, + + /** + * 关闭二维码弹窗 + */ + onCloseQrcodeModal() { + this.setData({ showQrcodeModal: false }) + }, + + /** + * 保存二维码 + */ + async onSaveQrcode() { + try { + const { qrcodeImageUrl } = this.data + if (!qrcodeImageUrl) { + wx.showToast({ title: '二维码链接不存在', icon: 'none' }) + return + } + + wx.showLoading({ title: '保存中...' }) + + let filePath = '' + + // 判断是否是 Base64 格式 + if (qrcodeImageUrl.startsWith('data:image')) { + const fs = wx.getFileSystemManager() + const [, format, bodyData] = /data:image\/(\w+);base64,(.*)/.exec(qrcodeImageUrl) || [] + if (!format || !bodyData) { + throw new Error('Base64 格式错误') + } + filePath = `${wx.env.USER_DATA_PATH}/qrcode_${Date.now()}.${format}` + fs.writeFileSync(filePath, bodyData, 'base64') + } else { + // 远程 URL 格式 + const downloadRes = await new Promise((resolve, reject) => { + wx.downloadFile({ + url: qrcodeImageUrl, + success: resolve, + fail: reject + }) + }) + + if (downloadRes.statusCode !== 200) { + throw new Error('下载图片失败') + } + filePath = downloadRes.tempFilePath + } + + // 保存到相册 + await new Promise((resolve, reject) => { + wx.saveImageToPhotosAlbum({ + filePath: filePath, + success: resolve, + fail: reject + }) + }) + + wx.hideLoading() + wx.showToast({ title: '保存成功', icon: 'success' }) + this.onCloseQrcodeModal() + } catch (err) { + wx.hideLoading() + console.error('保存二维码失败', err) + + if (err.errMsg && (err.errMsg.includes('auth deny') || err.errMsg.includes('auth denied'))) { + wx.showModal({ + title: '需要授权', + content: '请允许访问相册以保存二维码', + confirmText: '去设置', + success: (res) => { + if (res.confirm) { + wx.openSetting() + } + } + }) + } else { + wx.showToast({ title: err.message || '保存失败', icon: 'none' }) + } + } + }, + + /** + * 阻止冒泡 + */ + preventBubble() { + return + }, + + /** + * 点赞 + */ + async onLike(e) { + const id = e.currentTarget.dataset.id + const index = e.currentTarget.dataset.index + + if (!app.globalData.isLoggedIn) { + wx.navigateTo({ url: '/pages/login/login' }) + return + } + + try { + const res = await api.activity.toggleLike(id) + if (res.success) { + this.setData({ + [`activityList[${index}].isLiked`]: res.data.isLiked, + [`activityList[${index}].likes`]: res.data.likesCount + }) + } + } catch (err) { + console.error('点赞失败', err) + wx.showToast({ title: '操作失败', icon: 'none' }) + } + }, + + /** + * Tab bar 导航 + */ + switchTab(e) { + const path = e.currentTarget.dataset.path + + if (path === '/pages/chat/chat') { + if (!app.globalData.isLoggedIn) { + wx.navigateTo({ + url: '/pages/login/login?redirect=' + encodeURIComponent(path) + }) + return + } + } + wx.switchTab({ url: path }) + } +}) diff --git a/pages/entertainment/entertainment.json b/pages/entertainment/entertainment.json new file mode 100644 index 0000000..1aba645 --- /dev/null +++ b/pages/entertainment/entertainment.json @@ -0,0 +1,8 @@ +{ + "usingComponents": { + "app-icon": "/components/icon/icon" + }, + "navigationStyle": "custom", + "enablePullDownRefresh": true, + "backgroundTextStyle": "dark" +} diff --git a/pages/entertainment/entertainment.wxml b/pages/entertainment/entertainment.wxml new file mode 100644 index 0000000..a0af262 --- /dev/null +++ b/pages/entertainment/entertainment.wxml @@ -0,0 +1,230 @@ + + + + + + 休闲文娱 + + + + + + + + + + + + + + + + {{item.name}} + + + + + + + + + + + + + + + + + + + + + + + + + + + 精选活动 + + + 免费活动 + + + VIP活动 + + + SVIP活动 + + + + + + + + + + + + + + + + + + {{item.price}} + + + + + {{item.title}} + + + + {{item.date}} + + + + + {{item.location}} + + + + + {{item.heat}} + + + + + + + + {{item.participants}}人已报名 + + + + + + + + + + 暂无活动 + + + + + 没有更多活动了 ~ + + + + + + + + + + + + 陪伴 + + + + 文娱 + + + + 服务 + + + + + + 消息 + + + + 我的 + + + + + + + + + + + + + 请关注二维码 + 回复“报名”获取活动详情 + + + + + + 长按识别二维码或保存图片 + + + 保存二维码图片 + + + diff --git a/pages/entertainment/entertainment.wxss b/pages/entertainment/entertainment.wxss new file mode 100644 index 0000000..3f696b2 --- /dev/null +++ b/pages/entertainment/entertainment.wxss @@ -0,0 +1,729 @@ +/* 休闲文娱页面样式 - 玫瑰紫版 v3.0 */ +page { + background: #fff; +} + +.page-container { + min-height: 100vh; + background: #fff; + position: relative; +} + +/* 顶部导航栏已移除,改用全局 unified-header */ + +/* 内容滚动区域 */ +.content-scroll { + height: 100vh; + box-sizing: border-box; +} + +.content-scroll::-webkit-scrollbar { + display: none; +} + +/* 顶部轮播区域 - 与服务页保持一致 */ +.banner-section { + padding: 24rpx 32rpx; +} + +.banner-swiper { + width: 100%; + height: 400rpx; + border-radius: 32rpx; + overflow: hidden; + box-shadow: 0 8rpx 24rpx -8rpx rgba(243, 244, 246, 1), 0 20rpx 30rpx -6rpx rgba(243, 244, 246, 1); +} + +/* 防止轮播图闪烁 */ +swiper-item { + will-change: transform; + backface-visibility: hidden; + -webkit-backface-visibility: hidden; +} + +.banner-card { + width: 100%; + height: 100%; + border-radius: 32rpx; + overflow: hidden; + position: relative; + cursor: pointer; + /* 防止闪烁 */ + transform: translateZ(0); + -webkit-transform: translateZ(0); +} + +/* Banner图片 - 优化加载性能 */ +.banner-image { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + /* 防止图片闪烁 */ + backface-visibility: hidden; + -webkit-backface-visibility: hidden; + transform: translateZ(0); + -webkit-transform: translateZ(0); +} + +.banner-bg-gradient { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + background: linear-gradient(135deg, #F5E6ED 0%, #FAF5F8 100%); +} + +.banner-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(0deg, rgba(26, 26, 26, 0.4) 0%, rgba(26, 26, 26, 0) 50%); +} + +/* 轮播指示器已移除 */ + +/* 功能入口 - 与服务页面统一的图标风格 */ +.category-section { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + width: 718rpx; + margin: 0 auto; + padding: 24rpx 0; + row-gap: 64rpx; + background: transparent; +} + +.category-item { + width: 218rpx; + min-height: 208rpx; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + gap: 24rpx; + padding: 0; + box-sizing: border-box; + cursor: pointer; + transition: transform 0.2s ease; +} + +.category-item:active { + transform: scale(0.95); +} + +/* 图标容器:圆形图片 */ +.category-icon-container { + width: 168rpx; + height: 168rpx; + border-radius: 9999rpx; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + overflow: hidden; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* 图标图片 */ +.icon-svg { + width: 168rpx; + height: 168rpx; + border-radius: 9999rpx; +} + +.category-name { + font-family: Arial, sans-serif; + font-size: 36rpx; + font-weight: 700; + color: #364153; + line-height: 1.56; + text-align: center; + width: 218rpx; + transition: color 0.2s ease; +} + +.category-item:active .category-name { + color: #914584; +} + +/* 滚动公告栏 */ +.notice-bar { + margin: 32rpx; + padding: 28rpx 32rpx; + background: #fff; + border-radius: 40rpx; + border: 2rpx solid #F3F4F6; + box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04); + display: flex; + align-items: center; + gap: 24rpx; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.notice-bar:active { + transform: scale(0.98); + box-shadow: 0 2rpx 12rpx rgba(145, 69, 132, 0.12); +} + +.notice-icon { + width: 72rpx; + height: 72rpx; + background: linear-gradient(135deg, #FEF2F2 0%, #FFE2E2 100%); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + box-shadow: 0 2rpx 8rpx rgba(251, 44, 54, 0.15); + border: 2rpx solid rgba(251, 44, 54, 0.1); +} + +.heart-icon { + width: 44rpx; + height: 44rpx; + filter: drop-shadow(0 1rpx 2rpx rgba(251, 44, 54, 0.2)); +} + +.notice-content { + flex: 1; + height: 56rpx; + overflow: hidden; +} + +.notice-swiper { + height: 56rpx; +} + +.notice-text { + font-size: 36rpx; + font-weight: 600; + color: #1A1A1A; + line-height: 56rpx; +} + +.notice-text rich-text { + line-height: 56rpx; +} + +.notice-bar-empty { + height: 0; + margin: 0 32rpx; +} + +.notice-arrow { + width: 40rpx; + height: 40rpx; + opacity: 0.5; +} + +/* 活动标签切换 - 横向滚动 */ +.tab-section { + padding: 32rpx 0; + background: #fff; + margin: 0 32rpx 32rpx; + border-radius: 48rpx; + box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04); + position: relative; + z-index: 1; +} + +.tab-scroll { + width: 100%; + white-space: nowrap; +} + +.tab-scroll::-webkit-scrollbar { + display: none; +} + +.tab-list { + display: inline-flex; + gap: 20rpx; + padding: 0 32rpx; +} + +.tab-item { + padding: 20rpx 48rpx; + border-radius: 100rpx; + font-size: 36rpx; + font-weight: 700; + color: #6A7282; + background: #F3F4F6; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; + position: relative; + z-index: 2; + flex-shrink: 0; + white-space: nowrap; +} + +.tab-item:active { + transform: scale(0.96); +} + +.tab-item.active { + color: #fff; + background: linear-gradient(135deg, #914584 0%, #7A3A6F 100%); + box-shadow: 0 16rpx 32rpx rgba(145, 69, 132, 0.3); + transform: scale(1.02); +} + +/* 活动列表 */ +.activity-list { + padding: 0 32rpx; +} + +.activity-card { + background: #fff; + border-radius: 48rpx; + margin-bottom: 32rpx; + overflow: hidden; + box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; +} + +.activity-card:active { + transform: translateY(-2rpx) scale(0.98); + box-shadow: 0 2rpx 12rpx rgba(145, 69, 132, 0.12); +} + +.activity-image-wrap { + width: 100%; + height: 400rpx; + position: relative; + overflow: hidden; + border-radius: 32rpx 32rpx 0 0; +} + +/* 活动实际图片 */ +.activity-image { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + object-fit: cover; +} + +/* 活动图片渐变背景(降级方案) */ +.activity-image-gradient { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + background: linear-gradient(135deg, #F5E6ED 0%, #FAF5F8 100%); +} + +/* 点赞徽章 */ +.like-badge { + position: absolute; + top: 32rpx; + right: 32rpx; + display: flex; + align-items: center; + gap: 12rpx; + padding: 12rpx 28rpx 12rpx 16rpx; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(8px); + border-radius: 100rpx; + box-shadow: 0 4rpx 16rpx rgba(145, 69, 132, 0.12); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; +} + +.like-badge:active { + transform: scale(0.92); +} + +.like-badge.liked { + background: rgba(254, 242, 242, 0.98); + box-shadow: 0 4rpx 16rpx rgba(251, 44, 54, 0.3); +} + +.like-icon { + width: 36rpx; + height: 36rpx; + transition: transform 0.25s cubic-bezier(0.68, -0.55, 0.265, 1.55); +} + +.like-badge.liked .like-icon { + transform: scale(1.25); + animation: heartBeat 0.5s ease; +} + +@keyframes heartBeat { + 0%, 100% { transform: scale(1.25); } + 50% { transform: scale(1.4); } +} + +.like-count { + font-size: 32rpx; + font-weight: 700; + color: #4A5565; + transition: color 0.25s ease; +} + +.like-badge.liked .like-count { + color: #FB2C36; +} + +/* 价格标签 */ +.price-tag { + position: absolute; + bottom: 32rpx; + left: 32rpx; + padding: 12rpx 28rpx; + border-radius: 100rpx; + font-size: 32rpx; + font-weight: 700; + backdrop-filter: blur(4px); +} + +.price-tag.paid { + background: linear-gradient(135deg, #F97316 0%, #EA580C 100%); + color: #fff; + box-shadow: 0 4rpx 16rpx rgba(249, 115, 22, 0.35); +} + +.price-tag.free { + background: linear-gradient(135deg, #4ADE80 0%, #16A34A 100%); + color: #fff; + box-shadow: 0 4rpx 16rpx rgba(74, 222, 128, 0.35); +} + +/* 活动信息 */ +.activity-info { + padding: 40rpx; +} + +.activity-title { + font-size: 44rpx; + font-weight: 700; + color: #1A1A1A; + line-height: 1.3; + margin-bottom: 24rpx; + display: block; +} + +.activity-meta { + display: flex; + flex-direction: column; + gap: 20rpx; + margin-bottom: 24rpx; +} + +.meta-row { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.meta-item { + display: flex; + align-items: center; + gap: 20rpx; +} + +.meta-icon { + width: 40rpx; + height: 40rpx; + opacity: 0.6; +} + +.meta-text { + font-size: 32rpx; + color: #4A5565; + font-weight: 500; +} + +/* 热度显示样式 */ +.heat-item { + margin-left: auto; +} + +.heat-text { + color: #F97316; + font-weight: 700; +} + +/* 活动底部 */ +.activity-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 24rpx; + border-top: 3rpx solid #F3F4F6; +} + +.participants { + display: flex; + align-items: center; + gap: 24rpx; +} + +.avatar-stack { + display: flex; +} + +.mini-avatar { + width: 64rpx; + height: 64rpx; + border-radius: 50%; + background: linear-gradient(135deg, #F5E6ED 0%, #FAF5F8 100%); + border: 3rpx solid #fff; + margin-left: -16rpx; +} + +.mini-avatar:first-child { + margin-left: 0; +} + +.participant-text { + font-size: 32rpx; + color: #6A7282; +} + +.signup-btn { + width: 240rpx; + height: 80rpx; + display: flex; + align-items: center; + justify-content: center; + + background: linear-gradient(135deg, #914584 0%, #7A3A6F 100%); + border-radius: 100rpx; + font-size: 32rpx; + font-weight: 700; + color: #fff; + box-shadow: 0 16rpx 32rpx rgba(145, 69, 132, 0.3); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; +} + +.signup-btn:active { + transform: scale(0.96); + box-shadow: 0 8rpx 24rpx rgba(145, 69, 132, 0.4); +} + +.signup-btn.signed { + background: linear-gradient(135deg, #9CA3AF 0%, #6B7280 100%); + box-shadow: 0 4rpx 16rpx rgba(156, 163, 175, 0.25); +} + +/* 空状态 */ +.empty-state { + padding: 200rpx 0; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.empty-icon { + width: 240rpx; + height: 240rpx; + margin: 0 auto 32rpx; + opacity: 0.5; +} + +.empty-text { + font-size: 32rpx; + color: #99A1AF; +} + +/* 列表底部 */ +.list-footer { + padding: 60rpx 0; + text-align: center; +} + +.footer-text { + font-size: 36rpx; + color: #6A7282; +} + +/* 底部占位 */ +.bottom-placeholder { + height: 240rpx; +} + +/* 自定义底部导航栏 */ +.custom-tabbar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 194rpx; + background: #FFFFFF; + display: flex; + align-items: flex-start; + justify-content: space-around; + padding-top: 24rpx; + z-index: 999; + border-top: 2rpx solid #F3F4F6; +} + +.tabbar-item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12rpx; + width: 150rpx; + height: 120rpx; +} + +.tabbar-icon { + width: 68rpx; + height: 68rpx; +} + +.tabbar-text { + font-family: Arial, sans-serif; + font-size: 40rpx; + font-weight: 700; + color: #A58AA5; + line-height: 1; +} + +.tabbar-text.active { + color: #B06AB3; +} + +.message-icon-wrapper { + position: relative; + width: 68rpx; + height: 68rpx; +} + +/* 二维码引导弹窗 */ +.qrcode-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + visibility: hidden; + opacity: 0; + transition: all 0.3s ease; +} + +.qrcode-modal.show { + visibility: visible; + opacity: 1; +} + +.qrcode-modal .modal-mask { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4rpx); +} + +.qrcode-modal .modal-content { + position: relative; + width: 600rpx; + background: #FFFFFF; + border-radius: 48rpx; + padding: 60rpx 40rpx; + display: flex; + flex-direction: column; + align-items: center; + z-index: 1; + transform: scale(0.8); + transition: all 0.3s ease; +} + +.qrcode-modal.show .modal-content { + transform: scale(1); +} + +.qrcode-modal .close-btn { + position: absolute; + top: 30rpx; + right: 30rpx; + width: 60rpx; + height: 60rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.qrcode-modal .close-icon { + width: 32rpx; + height: 32rpx; +} + +.qrcode-modal .modal-title { + font-size: 40rpx; + font-weight: bold; + color: #333; + margin-bottom: 12rpx; +} + +.qrcode-modal .modal-subtitle { + font-size: 28rpx; + color: #666; + margin-bottom: 40rpx; +} + +.qrcode-modal .qrcode-container { + width: 400rpx; + height: 400rpx; + background: #f9f9f9; + border: 2rpx solid #eee; + border-radius: 24rpx; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 24rpx; + overflow: hidden; +} + +.qrcode-modal .qrcode-image { + width: 360rpx; + height: 360rpx; +} + +.qrcode-modal .modal-tips { + font-size: 24rpx; + color: #999; + margin-bottom: 40rpx; +} + +.qrcode-modal .save-btn { + width: 100%; + height: 88rpx; + background: #07C160; + color: #fff; + border-radius: 44rpx; + display: flex; + align-items: center; + justify-content: center; + font-size: 32rpx; + font-weight: bold; +} + +.qrcode-modal .save-btn:active { + opacity: 0.8; +} diff --git a/pages/gift-detail/gift-detail.js b/pages/gift-detail/gift-detail.js new file mode 100644 index 0000000..8e117d2 --- /dev/null +++ b/pages/gift-detail/gift-detail.js @@ -0,0 +1,185 @@ +const api = require('../../utils/api') +const auth = require('../../utils/auth') +const config = require('../../config/index') + +Page({ + data: { + giftId: '', + gift: null, + userLovePoints: 0, + loading: false + }, + + getGiftImageUrl(url) { + if (!url) return '' + if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url + if (url.startsWith('/images/gifts/')) { + const baseUrl = String(config.API_BASE_URL || '').replace(/\/api$/, '') + return baseUrl + url + } + return url + }, + + async onLoad(options) { + if (!options.id) { + wx.showToast({ title: '礼物ID缺失', icon: 'none' }) + setTimeout(() => wx.navigateBack(), 1500) + return + } + + this.setData({ giftId: options.id }) + + // 统一登录验证 + const isValid = await auth.ensureLogin({ + pageName: 'gift-detail', + redirectUrl: `/pages/gift-detail/gift-detail?id=${options.id}` + }) + + if (!isValid) return + + // 验证通过后,稍作延迟确保token稳定 + await new Promise(resolve => setTimeout(resolve, 50)) + + // 加载数据 + this.loadGiftDetail() + this.loadUserLovePoints() + }, + + async loadGiftDetail() { + this.setData({ loading: true }) + + try { + const res = await api.gifts.getDetail(this.data.giftId) + if (res.success) { + const gift = res.data || null + if (gift && gift.image) gift.image = this.getGiftImageUrl(gift.image) + this.setData({ + gift + }) + } + } catch (error) { + console.error('加载礼品详情失败:', error) + // 401错误由API层统一处理,这里只处理其他错误 + if (error.code !== 401) { + wx.showToast({ + title: error.message || '加载失败', + icon: 'none' + }) + } + } finally { + this.setData({ loading: false }) + } + }, + + async loadUserLovePoints() { + try { + const res = await api.loveExchange.getOptions() + if (res.success) { + this.setData({ + userLovePoints: res.data.current_love_points || 0 + }) + } + } catch (error) { + console.error('加载爱心值失败:', error) + // 401错误由API层统一处理 + } + }, + + // 兑换礼品 + async exchangeGift() { + const gift = this.data.gift + + // 检查爱心值是否足够 + if (this.data.userLovePoints < gift.love_cost) { + wx.showModal({ + title: '爱心值不足', + content: `需要 ${gift.love_cost} 爱心值,当前 ${this.data.userLovePoints} 爱心值`, + showCancel: false + }) + return + } + + // 检查库存 + if (gift.stock <= 0) { + wx.showToast({ + title: '库存不足', + icon: 'none' + }) + return + } + + // 获取收货地址 + try { + const address = await wx.chooseAddress() + + // 确认兑换 + wx.showModal({ + title: '确认兑换', + content: `确定使用 ${gift.love_cost} 爱心值兑换${gift.name}吗?`, + success: async (res) => { + if (res.confirm) { + await this.doExchange(address) + } + } + }) + } catch (error) { + if (error.errMsg && error.errMsg.includes('cancel')) { + // 用户取消选择地址 + return + } + console.error('获取地址失败:', error) + wx.showToast({ + title: '请授权收货地址', + icon: 'none' + }) + } + }, + + async doExchange(address) { + wx.showLoading({ title: '兑换中...' }) + + try { + const res = await api.gifts.exchange({ + giftId: this.data.giftId, + shippingInfo: { + name: address.userName, + phone: address.telNumber, + address: `${address.provinceName}${address.cityName}${address.countyName}${address.detailInfo}` + } + }) + + wx.hideLoading() + + if (res.success) { + wx.showModal({ + title: '兑换成功', + content: res.message || '礼品兑换成功,请在兑换记录中查看物流信息', + showCancel: false, + success: () => { + wx.navigateTo({ + url: '/pages/gift-exchanges/gift-exchanges' + }) + } + }) + } + } catch (error) { + wx.hideLoading() + console.error('兑换失败:', error) + wx.showToast({ + title: error.message || '兑换失败', + icon: 'none' + }) + } + }, + + // 预览图片 + previewImage() { + const imageUrl = this.data.gift && (this.data.gift.image || this.data.gift.image_url) + if (imageUrl) { + wx.previewImage({ + urls: [imageUrl], + current: imageUrl + }) + } + } +}) diff --git a/pages/gift-detail/gift-detail.json b/pages/gift-detail/gift-detail.json new file mode 100644 index 0000000..1ce92c4 --- /dev/null +++ b/pages/gift-detail/gift-detail.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "礼品详情", + "navigationBarBackgroundColor": "#F2EDFF", + "navigationBarTextStyle": "black" +} diff --git a/pages/gift-detail/gift-detail.wxml b/pages/gift-detail/gift-detail.wxml new file mode 100644 index 0000000..165584c --- /dev/null +++ b/pages/gift-detail/gift-detail.wxml @@ -0,0 +1,55 @@ + + + + + + + + + + + {{gift.name}} + + + {{gift.love_cost}} + + + + + + 分类: + {{gift.category}} + + + 库存: + {{gift.stock}} + + + + + 商品详情 + {{gift.description}} + + + + + + + 我的爱心值 + {{userLovePoints}} + + + + + + + + 加载中... + + diff --git a/pages/gift-detail/gift-detail.wxss b/pages/gift-detail/gift-detail.wxss new file mode 100644 index 0000000..382a06d --- /dev/null +++ b/pages/gift-detail/gift-detail.wxss @@ -0,0 +1,161 @@ +.gift-detail-container { + min-height: 100vh; + background: #F3F4F6; + padding-bottom: 160rpx; +} + +.image-section { + width: 100%; + height: 600rpx; + background: #FFFFFF; +} + +.gift-image { + width: 100%; + height: 100%; +} + +.info-section { + background: #FFFFFF; + margin-top: 20rpx; + padding: 40rpx; +} + +.gift-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 32rpx; + padding-bottom: 32rpx; + border-bottom: 2rpx solid #E5E7EB; +} + +.gift-name { + flex: 1; + font-size: 40rpx; + font-weight: bold; + color: #1F2937; + margin-right: 32rpx; +} + +.gift-price { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.heart-icon { + width: 40rpx; + height: 40rpx; + margin-right: 8rpx; +} + +.price-text { + font-size: 48rpx; + font-weight: bold; + color: #A78BFA; +} + +.gift-meta { + margin-bottom: 32rpx; +} + +.meta-item { + display: flex; + align-items: center; + margin-bottom: 16rpx; +} + +.meta-label { + font-size: 28rpx; + color: #6B7280; + margin-right: 16rpx; +} + +.meta-value { + font-size: 28rpx; + color: #1F2937; +} + +.gift-description { + margin-top: 32rpx; + padding-top: 32rpx; + border-top: 2rpx solid #E5E7EB; +} + +.description-title { + display: block; + font-size: 32rpx; + font-weight: 600; + color: #1F2937; + margin-bottom: 24rpx; +} + +.description-text { + display: block; + font-size: 28rpx; + color: #6B7280; + line-height: 1.8; +} + +.bottom-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: #FFFFFF; + padding: 24rpx 40rpx; + display: flex; + align-items: center; + box-shadow: 0 -4rpx 12rpx rgba(0, 0, 0, 0.05); + z-index: 100; +} + +.balance-info { + flex: 1; + margin-right: 32rpx; +} + +.balance-label { + display: block; + font-size: 24rpx; + color: #6B7280; + margin-bottom: 8rpx; +} + +.balance-value { + display: block; + font-size: 36rpx; + font-weight: bold; + color: #A78BFA; +} + +.exchange-btn { + flex-shrink: 0; + background: linear-gradient(135deg, #A78BFA 0%, #C084FC 100%); + color: #FFFFFF; + border-radius: 50rpx; + font-size: 32rpx; + padding: 24rpx 64rpx; + box-shadow: 0 4rpx 12rpx rgba(167, 139, 250, 0.3); +} + +.exchange-btn::after { + border: none; +} + +.exchange-btn.disabled { + background: #E5E7EB; + color: #9CA3AF; + box-shadow: none; +} + +.loading-state { + padding: 200rpx 0; + text-align: center; +} + +.loading-text { + font-size: 28rpx; + color: #9CA3AF; +} diff --git a/pages/gift-exchanges/gift-exchanges.js b/pages/gift-exchanges/gift-exchanges.js new file mode 100644 index 0000000..75c2fec --- /dev/null +++ b/pages/gift-exchanges/gift-exchanges.js @@ -0,0 +1,113 @@ +const api = require('../../utils/api') +const auth = require('../../utils/auth') + +Page({ + data: { + exchanges: [], + loading: false + }, + + async onLoad() { + // 统一登录验证 + const isValid = await auth.ensureLogin({ + pageName: 'gift-exchanges', + redirectUrl: '/pages/gift-exchanges/gift-exchanges' + }) + + if (!isValid) return + + // 验证通过后,稍作延迟确保token稳定 + await new Promise(resolve => setTimeout(resolve, 50)) + + // 加载数据 + this.loadExchanges() + }, + + onShow() { + // 每次显示页面时刷新数据(已登录的情况下) + const app = getApp() + if (app.globalData.isLoggedIn) { + this.loadExchanges() + } + }, + + async loadExchanges() { + if (this.data.loading) return + + this.setData({ loading: true }) + + try { + const res = await api.gifts.getMyExchanges({ limit: 50 }) + if (res.success) { + this.setData({ + exchanges: res.data + }) + } + } catch (error) { + console.error('加载兑换记录失败:', error) + // 401错误由API层统一处理,这里只处理其他错误 + if (error.code !== 401) { + wx.showToast({ + title: error.message || '加载失败', + icon: 'none' + }) + } + } finally { + this.setData({ loading: false }) + } + }, + + // 查看物流 + viewTracking(e) { + const trackingNumber = e.currentTarget.dataset.tracking + if (!trackingNumber) { + wx.showToast({ + title: '暂无物流信息', + icon: 'none' + }) + return + } + + // 复制物流单号 + wx.setClipboardData({ + data: trackingNumber, + success: () => { + wx.showToast({ + title: '物流单号已复制', + icon: 'success' + }) + } + }) + }, + + // 格式化状态 + formatStatus(status) { + const statusMap = { + 'pending': '待发货', + 'shipped': '已发货', + 'delivered': '已送达', + 'cancelled': '已取消' + } + return statusMap[status] || status + }, + + // 格式化时间 + formatTime(dateStr) { + const date = new Date(dateStr) + return date.toLocaleDateString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }) + }, + + // 下拉刷新 + onPullDownRefresh() { + this.loadExchanges() + setTimeout(() => { + wx.stopPullDownRefresh() + }, 1000) + } +}) diff --git a/pages/gift-exchanges/gift-exchanges.json b/pages/gift-exchanges/gift-exchanges.json new file mode 100644 index 0000000..67a840c --- /dev/null +++ b/pages/gift-exchanges/gift-exchanges.json @@ -0,0 +1,7 @@ +{ + "navigationBarTitleText": "兑换记录", + "navigationBarBackgroundColor": "#F2EDFF", + "navigationBarTextStyle": "black", + "enablePullDownRefresh": true, + "backgroundColor": "#F3F4F6" +} diff --git a/pages/gift-exchanges/gift-exchanges.wxml b/pages/gift-exchanges/gift-exchanges.wxml new file mode 100644 index 0000000..c624186 --- /dev/null +++ b/pages/gift-exchanges/gift-exchanges.wxml @@ -0,0 +1,61 @@ + + + + + 订单号:{{item.order_number}} + {{formatStatus(item.status)}} + + + + + + {{item.gift_name}} + + + {{item.love_cost}} + + + + + + + 收货人: + {{item.shipping_name}} + + + 联系电话: + {{item.shipping_phone}} + + + 收货地址: + {{item.shipping_address}} + + + 物流单号: + + {{item.tracking_number}} + + + + 兑换时间:{{formatTime(item.created_at)}} + + + + + + + + + 暂无兑换记录 + + + + + + 加载中... + + diff --git a/pages/gift-exchanges/gift-exchanges.wxss b/pages/gift-exchanges/gift-exchanges.wxss new file mode 100644 index 0000000..8c6548d --- /dev/null +++ b/pages/gift-exchanges/gift-exchanges.wxss @@ -0,0 +1,185 @@ +.exchanges-container { + min-height: 100vh; + background: #F3F4F6; + padding: 40rpx; +} + +.exchanges-list { + display: flex; + flex-direction: column; + gap: 24rpx; +} + +.exchange-card { + background: #FFFFFF; + border-radius: 16rpx; + overflow: hidden; + box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05); +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 24rpx 32rpx; + background: #F9FAFB; + border-bottom: 2rpx solid #E5E7EB; +} + +.order-number { + font-size: 26rpx; + color: #6B7280; +} + +.status { + font-size: 26rpx; + font-weight: 500; + padding: 8rpx 20rpx; + border-radius: 20rpx; +} + +.status.pending { + background: #FEF3C7; + color: #D97706; +} + +.status.shipped { + background: #DBEAFE; + color: #2563EB; +} + +.status.delivered { + background: #D1FAE5; + color: #059669; +} + +.status.cancelled { + background: #FEE2E2; + color: #DC2626; +} + +.card-body { + display: flex; + padding: 32rpx; + border-bottom: 2rpx solid #E5E7EB; +} + +.gift-image { + width: 160rpx; + height: 160rpx; + border-radius: 12rpx; + background: #F3F4F6; + flex-shrink: 0; +} + +.gift-info { + flex: 1; + margin-left: 24rpx; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.gift-name { + font-size: 32rpx; + color: #1F2937; + font-weight: 500; +} + +.gift-cost { + display: flex; + align-items: center; +} + +.heart-icon { + width: 32rpx; + height: 32rpx; + margin-right: 8rpx; +} + +.cost-text { + font-size: 32rpx; + font-weight: bold; + color: #A78BFA; +} + +.card-footer { + padding: 24rpx 32rpx; +} + +.shipping-info { + display: flex; + margin-bottom: 16rpx; +} + +.info-label { + font-size: 26rpx; + color: #6B7280; + flex-shrink: 0; + width: 140rpx; +} + +.info-value { + flex: 1; + font-size: 26rpx; + color: #1F2937; +} + +.info-value.tracking { + color: #A78BFA; + text-decoration: underline; +} + +.exchange-time { + margin-top: 16rpx; + padding-top: 16rpx; + border-top: 2rpx solid #E5E7EB; +} + +.time-text { + font-size: 24rpx; + color: #9CA3AF; +} + +.empty-state { + padding: 200rpx 0; + text-align: center; +} + +.empty-icon { + width: 200rpx; + height: 200rpx; + margin-bottom: 32rpx; + opacity: 0.5; +} + +.empty-text { + display: block; + font-size: 28rpx; + color: #9CA3AF; + margin-bottom: 40rpx; +} + +.goto-shop-btn { + background: linear-gradient(135deg, #A78BFA 0%, #C084FC 100%); + color: #FFFFFF; + border-radius: 50rpx; + font-size: 32rpx; + padding: 24rpx 64rpx; + width: 400rpx; + margin: 0 auto; +} + +.goto-shop-btn::after { + border: none; +} + +.loading-state { + padding: 80rpx 0; + text-align: center; +} + +.loading-text { + font-size: 28rpx; + color: #9CA3AF; +} diff --git a/pages/gift-shop/gift-shop.js b/pages/gift-shop/gift-shop.js new file mode 100644 index 0000000..25cdf27 --- /dev/null +++ b/pages/gift-shop/gift-shop.js @@ -0,0 +1,141 @@ +const api = require('../../utils/api') +const auth = require('../../utils/auth') +const config = require('../../config/index') + +Page({ + data: { + statusBarHeight: 44, + navBarHeight: 44, + totalNavHeight: 88, + lovePoints: 0, + gifts: [], + giftsLoading: false, + giftsError: '', + skeletonGifts: Array.from({ length: 6 }).map((_, idx) => ({ id: idx + 1 })) + }, + + getGiftImageUrl(url) { + if (!url) return '' + if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url + // 兼容所有相对路径 + if (url.startsWith('/')) { + const baseUrl = String(config.API_BASE_URL || '').replace(/\/api$/, '') + return baseUrl + url + } + return url + }, + + async onLoad() { + // 计算导航栏高度 + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + const menuButton = wx.getMenuButtonBoundingClientRect() + const navBarHeight = menuButton.height + (menuButton.top - statusBarHeight) * 2 + const totalNavHeight = statusBarHeight + navBarHeight + + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight + }) + + this.loadGifts() + + const isValid = await auth.ensureLogin({ + pageName: 'gift-shop', + redirectUrl: '/pages/gift-shop/gift-shop' + }) + + if (!isValid) return + + await new Promise(resolve => setTimeout(resolve, 50)) + this.loadLovePoints() + }, + + onShow() { + const app = getApp() + if (app.globalData.isLoggedIn) { + this.loadLovePoints() + } + }, + + // 加载爱心值 + async loadLovePoints() { + try { + const res = await api.loveExchange.getOptions() + if (res.success) { + this.setData({ + lovePoints: res.data.current_love_points || 0 + }) + } + } catch (error) { + console.error('加载爱心值失败:', error) + } + }, + + async loadGifts() { + if (this.data.giftsLoading) return + + this.setData({ + giftsLoading: true, + giftsError: '' + }) + + try { + // 使用 shop.getItems 获取 exchange_items 数据 + const res = await api.shop.getItems() + if (res.success) { + const gifts = (res.data || []).map(gift => ({ + id: gift.id, + name: gift.name, + // 兼容后端返回 image 或 image_url + image: this.getGiftImageUrl(gift.image_url || gift.image), + // 兼容后端返回 price 或 love_cost + love_cost: gift.price || gift.love_cost, + stock: gift.stock, + sold_count: gift.sold_count, + category: gift.category + })) + this.setData({ gifts }) + } else { + this.setData({ giftsError: res.error || '获取礼品失败' }) + } + } catch (error) { + console.error('加载礼品失败:', error) + this.setData({ giftsError: error.message || '获取礼品失败' }) + } finally { + this.setData({ giftsLoading: false }) + } + }, + + // 返回上一页 + goBack() { + wx.navigateBack() + }, + + // 查看明细 + onViewDetails() { + wx.navigateTo({ + url: '/pages/love-transactions/love-transactions' + }) + }, + + // 点击礼品卡片 + onGiftTap(e) { + wx.showModal({ + title: '提示', + content: '请下载app端进行兑换', + showCancel: false, + confirmText: '知道了' + }) + }, + + // 下拉刷新 + onPullDownRefresh() { + this.loadLovePoints() + this.loadGifts() + setTimeout(() => { + wx.stopPullDownRefresh() + }, 1000) + } +}) diff --git a/pages/gift-shop/gift-shop.json b/pages/gift-shop/gift-shop.json new file mode 100644 index 0000000..ad7cf18 --- /dev/null +++ b/pages/gift-shop/gift-shop.json @@ -0,0 +1,5 @@ +{ + "navigationStyle": "custom", + "enablePullDownRefresh": true, + "backgroundColor": "#FAF8FC" +} diff --git a/pages/gift-shop/gift-shop.wxml b/pages/gift-shop/gift-shop.wxml new file mode 100644 index 0000000..a3e6271 --- /dev/null +++ b/pages/gift-shop/gift-shop.wxml @@ -0,0 +1,93 @@ + + + + + + + + + 礼品商城 + + + + + + + + + + + + + + + + 我的爱心 + + {{lovePoints}} + + 点击查看明细 + + + + + + + + + + + + + + 超值兑换 + + + + + + + + + + + + + + + {{giftsError}} + 重试 + + + + + 暂无可兑换礼品 + + + + + + + 热兑 + + + {{item.name}} + + + + + {{item.love_cost}} + + 兑换 + + + + + + + + 更多精美礼品持续上新中... + + + + diff --git a/pages/gift-shop/gift-shop.wxss b/pages/gift-shop/gift-shop.wxss new file mode 100644 index 0000000..f876e27 --- /dev/null +++ b/pages/gift-shop/gift-shop.wxss @@ -0,0 +1,387 @@ +.page-container { + min-height: 100vh; + background: #FAF8FC; +} + +.nav-container { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 999; + background: transparent; +} + +.status-bar { + background: transparent; +} + +.nav-bar { + display: flex; + align-items: center; + justify-content: center; + padding: 0 32rpx; + background: transparent; + position: relative; +} + +.nav-back { + position: absolute; + left: 32rpx; + top: 50%; + transform: translateY(-50%); + width: 80rpx; + height: 80rpx; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +.back-icon { + width: 48rpx; + height: 48rpx; +} + +.nav-title { + font-size: 36rpx; + font-weight: 700; + color: #101828; + line-height: 1; +} + +.content-scroll { + height: 100vh; + box-sizing: border-box; +} + +.content-wrap { + padding: 30rpx 31rpx 0; + box-sizing: border-box; +} + +.love-card { + width: 688rpx; + height: 313rpx; + border-radius: 46rpx; + overflow: hidden; + position: relative; + box-shadow: 0px 8px 10px -6px rgba(252, 206, 232, 0.5), 0px 20px 25px -5px rgba(252, 206, 232, 0.5); +} + +.love-card-bg { + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(176, 106, 179, 1) 0%, rgba(212, 137, 190, 1) 100%); +} + +.love-card-blur { + position: absolute; + width: 366rpx; + height: 366rpx; + top: -92rpx; + right: -92rpx; + border-radius: 9999rpx; + background: rgba(255, 255, 255, 0.1); + filter: blur(128px); +} + +.love-card-bottom-shade { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 156rpx; + background: linear-gradient(0deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0) 100%); +} + +.love-card-content { + position: relative; + height: 100%; + padding: 46rpx; + display: flex; + align-items: center; + justify-content: space-between; + box-sizing: border-box; +} + +.love-info { + display: flex; + flex-direction: column; + justify-content: center; +} + +.love-header { + display: flex; + align-items: center; + gap: 15rpx; +} + +.love-icon { + width: 61rpx; + height: 61rpx; +} + +.love-label { + font-size: 38rpx; + font-weight: 700; + line-height: 53rpx; + letter-spacing: 1rpx; + color: #FFFFFF; +} + +.love-value { + margin-top: 8rpx; + font-size: 92rpx; + font-weight: 900; + line-height: 92rpx; + letter-spacing: -2rpx; + color: #FFFFFF; + text-shadow: 0px 1px 4px rgba(0, 0, 0, 0.15); +} + +.love-action { + margin-top: 6rpx; + display: flex; + align-items: center; + gap: 8rpx; + opacity: 0.8; +} + +.love-action-text { + font-size: 31rpx; + font-weight: 700; + line-height: 47rpx; + color: #FFFFFF; +} + +.love-action-arrow { + width: 30rpx; + height: 30rpx; +} + +.love-decoration { + width: 134rpx; + height: 134rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.decoration-icon { + width: 134rpx; + height: 134rpx; +} + +.gifts-section { + margin-top: 30rpx; +} + +.gifts-header { + display: flex; + align-items: center; + gap: 15rpx; + padding-left: 8rpx; +} + +.gifts-icon { + width: 46rpx; + height: 46rpx; +} + +.gifts-title { + font-size: 46rpx; + font-weight: 700; + color: #101828; + line-height: 61rpx; +} + +.gifts-grid { + margin-top: 30rpx; + display: grid; + grid-template-columns: 333rpx 333rpx; + gap: 23rpx; +} + +.gift-card { + width: 333rpx; + height: 485rpx; + background: #FFFFFF; + border: 2rpx solid #F9FAFB; + border-radius: 31rpx; + box-shadow: 0px 1px 2px -1px rgba(0, 0, 0, 0.1), 0px 1px 3px 0px rgba(0, 0, 0, 0.1); + padding: 25rpx; + box-sizing: border-box; + display: flex; + flex-direction: column; +} + +.gift-image-wrap { + width: 283rpx; + height: 283rpx; + background: #F9FAFB; + border-radius: 27rpx; + overflow: hidden; + position: relative; +} + +.gift-image { + width: 100%; + height: 100%; +} + +.gift-image-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.05); +} + +.gift-badge { + position: absolute; + right: 15rpx; + bottom: 15rpx; + width: 76rpx; + height: 38rpx; + border-radius: 9999rpx; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + font-size: 23rpx; + font-weight: 700; + line-height: 31rpx; + color: #FFFFFF; +} + +.gift-name { + margin-top: 23rpx; + font-size: 34rpx; + font-weight: 700; + line-height: 53rpx; + color: #101828; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.gift-footer { + margin-top: 15rpx; + display: flex; + align-items: center; + justify-content: space-between; +} + +.gift-price { + display: flex; + align-items: center; + gap: 8rpx; +} + +.gift-heart-icon { + width: 34rpx; + height: 34rpx; +} + +.gift-price-text { + font-size: 34rpx; + font-weight: 900; + line-height: 53rpx; + color: #B06AB3; +} + +.gift-exchange-btn { + width: 99rpx; + height: 61rpx; + border-radius: 19rpx; + background: #B06AB3; + display: flex; + align-items: center; + justify-content: center; + font-size: 27rpx; + font-weight: 700; + line-height: 38rpx; + color: #FFFFFF; + box-shadow: 0px 2px 4px -2px rgba(176, 106, 179, 0.2), 0px 4px 6px -1px rgba(176, 106, 179, 0.2); +} + +.gifts-error { + margin-top: 30rpx; + padding: 32rpx; + border-radius: 24rpx; + background: #FFFFFF; + border: 2rpx solid #F9FAFB; +} + +.gifts-empty { + margin-top: 30rpx; + padding: 60rpx 32rpx; + border-radius: 24rpx; + background: #FFFFFF; + border: 2rpx solid #F9FAFB; + text-align: center; +} + +.gifts-empty-text { + font-size: 28rpx; + color: #99A1AF; +} + +.gifts-error-text { + font-size: 28rpx; + color: #6B7280; +} + +.gifts-retry { + margin-top: 18rpx; + width: 160rpx; + height: 60rpx; + border-radius: 19rpx; + background: #B06AB3; + display: flex; + align-items: center; + justify-content: center; + font-size: 28rpx; + font-weight: 700; + color: #FFFFFF; +} + +.gift-card.skeleton { + box-shadow: none; +} + +.gift-card.skeleton .gift-image-wrap { + background: #F3F4F6; +} + +.gift-name.skeleton-line, +.gift-price.skeleton-line { + height: 28rpx; + border-radius: 14rpx; + background: #F3F4F6; +} + +.gift-name.skeleton-line { + margin-top: 23rpx; +} + +.gift-price.skeleton-line { + width: 140rpx; +} + +.gift-exchange-btn.skeleton-btn { + width: 99rpx; + height: 61rpx; + background: #F3F4F6; + box-shadow: none; +} + +.bottom-tip { + padding: 60rpx 0 40rpx; + text-align: center; +} + +.tip-text { + font-size: 27rpx; + line-height: 40rpx; + color: #99A1AF; +} diff --git a/pages/happy-school/happy-school.js b/pages/happy-school/happy-school.js new file mode 100644 index 0000000..440d5e0 --- /dev/null +++ b/pages/happy-school/happy-school.js @@ -0,0 +1,394 @@ +// pages/happy-school/happy-school.js - 快乐学堂页面 +const api = require('../../utils/api') +const app = getApp() + +Page({ + data: { + statusBarHeight: 44, + navBarHeight: 44, + totalNavHeight: 88, + loading: false, + loadingMore: false, + activeTab: 'featured', + + // 活动列表 + activityList: [], + + // 分页相关 + page: 1, + limit: 20, + hasMore: true, + total: 0, + + // 二维码弹窗 + showQrcodeModal: false, + qrcodeImageUrl: 'https://ai-c.maimanji.com/images/qrcode-happy-school.jpg' + }, + + onLoad() { + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + const menuButton = wx.getMenuButtonBoundingClientRect() + const navBarHeight = menuButton.height + (menuButton.top - statusBarHeight) * 2 + const totalNavHeight = statusBarHeight + navBarHeight + + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight + }) + + this.loadActivityList() + }, + + /** + * 返回上一页 + */ + onBack() { + wx.navigateBack() + }, + + /** + * 切换活动标签 + */ + onTabChange(e) { + const tab = e.currentTarget.dataset.tab + if (tab === this.data.activeTab) return + + this.setData({ + activeTab: tab, + activityList: [], + page: 1, + hasMore: true + }) + this.loadActivityList() + }, + + /** + * 下拉刷新 + */ + onPullDownRefresh() { + this.loadActivityList(false).finally(() => { + wx.stopPullDownRefresh() + }) + }, + + /** + * 上拉加载更多 + */ + onReachBottom() { + if (this.data.hasMore && !this.data.loadingMore && !this.data.loading) { + this.loadActivityList(true) + } + }, + + /** + * 加载活动列表 - 根据categoryName筛选快乐学堂(支持分页) + */ + async loadActivityList(isLoadMore = false) { + if (isLoadMore) { + this.setData({ loadingMore: true }) + } else { + this.setData({ loading: true, page: 1, hasMore: true, activityList: [] }) + } + + try { + const { activeTab, page, limit } = this.data + const params = { + category: 'school', + limit: limit, + page: page + } + + if (activeTab === 'featured') { + params.tab = 'featured' + } else if (activeTab === 'free') { + params.priceType = 'free' + } else if (activeTab === 'vip') { + params.is_vip = true + } else if (activeTab === 'svip') { + params.is_svip = true + } + + const res = await api.activity.getList(params) + + if (res.success && res.data && res.data.list) { + const total = res.data.total || 0 + const allActivities = res.data.list + const schoolActivities = allActivities.filter(item => item.categoryName === '快乐学堂') + + let clubQrcode = '' + const firstWithQrcode = schoolActivities.find(item => item.activityGuideQrcode || item.activity_guide_qrcode) + if (firstWithQrcode && !isLoadMore) { + clubQrcode = firstWithQrcode.activityGuideQrcode || firstWithQrcode.activity_guide_qrcode + } + + const newActivityList = schoolActivities.map(item => { + const heat = item.heat || (item.likes * 2 + (item.views || 0) + (item.current_participants || 0) * 3) + + return { + id: item.id, + title: item.title, + date: this.formatDate(item.start_date || item.activityDate), + location: item.location || '', + venue: item.venue || '', + image: item.coverImage || item.cover_image || '', + heat: Math.floor(heat), + price: item.price_text || item.priceText || '免费', + priceType: item.is_free || item.priceType === 'free' ? 'free' : 'paid', + likes: item.likes || item.likesCount || 0, + participants: item.current_participants || item.currentParticipants || 0, + isLiked: item.is_liked || item.isLiked || false, + isSignedUp: item.is_registered || item.isSignedUp || false, + status: item.status || (item.currentParticipants >= item.maxParticipants && item.maxParticipants > 0 ? 'full' : 'upcoming'), + activityGuideQrcode: item.activityGuideQrcode || item.activity_guide_qrcode || '' + } + }) + + const hasMore = newActivityList.length >= limit && (this.data.activityList.length + newActivityList.length) < total + + if (isLoadMore) { + this.setData({ + activityList: [...this.data.activityList, ...newActivityList], + loadingMore: false, + hasMore, + page: this.data.page + 1, + total + }) + } else { + this.setData({ + activityList: newActivityList, + hasMore, + total, + qrcodeImageUrl: clubQrcode || this.data.qrcodeImageUrl + }) + } + + console.log('[happy-school] 加载成功,总数:', total, '当前:', this.data.activityList.length, 'hasMore:', hasMore) + } else { + if (isLoadMore) { + this.setData({ loadingMore: false, hasMore: false }) + } else { + this.setData({ activityList: [], hasMore: false }) + } + } + } catch (err) { + console.error('加载活动列表失败', err) + if (isLoadMore) { + this.setData({ loadingMore: false }) + } else { + this.setData({ activityList: [], loading: false }) + } + } finally { + if (!isLoadMore) { + this.setData({ loading: false }) + } + } + }, + + /** + * 格式化日期 + */ + formatDate(dateStr) { + if (!dateStr) return '' + const date = new Date(dateStr) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}年${month}月${day}日` + }, + + /** + * 活动卡片点击 + */ + onActivityTap(e) { + const id = e.currentTarget.dataset.id + wx.navigateTo({ + url: `/pages/activity-detail/activity-detail?id=${id}` + }) + }, + + /** + * 点赞 + */ + async onLike(e) { + const id = e.currentTarget.dataset.id + const index = e.currentTarget.dataset.index + + if (!app.globalData.isLoggedIn) { + wx.navigateTo({ url: '/pages/login/login' }) + return + } + + try { + const res = await api.activity.toggleLike(id) + if (res.success) { + this.setData({ + [`activityList[${index}].isLiked`]: res.data.isLiked, + [`activityList[${index}].likes`]: res.data.likesCount + }) + } + } catch (err) { + console.error('点赞失败', err) + wx.showToast({ title: '操作失败', icon: 'none' }) + } + }, + + /** + * 报名按钮点击 + */ + async onSignUp(e) { + const id = e.currentTarget.dataset.id + const index = e.currentTarget.dataset.index + + if (!app.globalData.isLoggedIn) { + wx.navigateTo({ url: '/pages/login/login' }) + return + } + + const activity = this.data.activityList[index] + + // 检查活动状态 + if (activity.status === 'full' || activity.status === 'ended') { + const qrCode = activity.activityGuideQrcode || activity.activity_guide_qrcode || this.data.qrcodeImageUrl || 'https://ai-c.maimanji.com/api/common/qrcode?type=group' + this.setData({ + qrcodeImageUrl: qrCode, + showQrcodeModal: true + }) + return + } + + try { + if (activity.isSignedUp) { + // 取消报名 + const res = await api.activity.cancelSignup(id) + if (res.success) { + wx.showToast({ title: '已取消报名', icon: 'success' }) + this.loadActivityList() // 刷新列表获取最新人数 + } + } else { + // 报名 + const res = await api.activity.signup(id) + if (res.success) { + wx.showToast({ title: '报名成功', icon: 'success' }) + this.loadActivityList() // 刷新列表获取最新人数 + } else { + // 检查是否需要显示二维码(后端开关关闭或活动已结束) + if (res.code === 'QR_CODE_REQUIRED' || res.error === 'QR_CODE_REQUIRED' || res.code === 'ACTIVITY_ENDED' || res.error === '活动已结束') { + if (activity.activityGuideQrcode || activity.activity_guide_qrcode) { + this.setData({ qrcodeImageUrl: activity.activityGuideQrcode || activity.activity_guide_qrcode }) + } + this.setData({ showQrcodeModal: true }) + if (res.code === 'ACTIVITY_ENDED' || res.error === '活动已结束') { + wx.showToast({ title: '活动已结束,进群查看更多', icon: 'none' }) + } + } else { + wx.showToast({ + title: res.error || '报名失败', + icon: 'none' + }) + } + } + } + } catch (err) { + console.error('报名操作失败', err) + // 捕获特定错误码以显示二维码 + const isQrRequired = err && (err.code === 'QR_CODE_REQUIRED' || (err.data && err.data.code === 'QR_CODE_REQUIRED')) + const isActivityEnded = err && (err.code === 'ACTIVITY_ENDED' || (err.data && err.data.code === 'ACTIVITY_ENDED') || err.error === '活动已结束') + + if (isQrRequired || isActivityEnded) { + if (activity.activityGuideQrcode || activity.activity_guide_qrcode) { + this.setData({ qrcodeImageUrl: activity.activityGuideQrcode || activity.activity_guide_qrcode }) + } + this.setData({ showQrcodeModal: true }) + if (isActivityEnded) { + wx.showToast({ title: '活动已结束,进群查看更多', icon: 'none' }) + } + } else { + wx.showToast({ + title: err.error || err.message || '操作失败', + icon: 'none' + }) + } + } + }, + + /** + * 加入群组 + */ + onJoinGroup() { + // 如果没有二维码,尝试获取第一个活动的二维码 + if (!this.data.qrcodeImageUrl && this.data.activityList.length > 0) { + const firstWithQrcode = this.data.activityList.find(item => item.activityGuideQrcode || item.activity_guide_qrcode) + if (firstWithQrcode) { + this.setData({ qrcodeImageUrl: firstWithQrcode.activityGuideQrcode || firstWithQrcode.activity_guide_qrcode }) + } + } + this.setData({ showQrcodeModal: true }) + }, + + /** + * 阻止冒泡 + */ + preventBubble() { + return + }, + + /** + * 关闭二维码弹窗 + */ + onCloseQrcodeModal() { + this.setData({ showQrcodeModal: false }) + }, + + /** + * 保存二维码 + */ + async onSaveQrcode() { + try { + const { qrcodeImageUrl } = this.data + + // 下载图片到本地 + const downloadRes = await new Promise((resolve, reject) => { + wx.downloadFile({ + url: qrcodeImageUrl, + success: resolve, + fail: reject + }) + }) + + if (downloadRes.statusCode !== 200) { + throw new Error('下载失败') + } + + // 保存到相册 + await new Promise((resolve, reject) => { + wx.saveImageToPhotosAlbum({ + filePath: downloadRes.tempFilePath, + success: resolve, + fail: reject + }) + }) + + wx.showToast({ title: '保存成功', icon: 'success' }) + this.onCloseQrcodeModal() + } catch (err) { + console.error('保存二维码失败', err) + + if (err.errMsg && err.errMsg.includes('auth deny')) { + wx.showModal({ + title: '需要授权', + content: '请允许访问相册以保存二维码', + confirmText: '去设置', + success: (res) => { + if (res.confirm) { + wx.openSetting() + } + } + }) + } else { + wx.showToast({ title: '保存失败', icon: 'none' }) + } + } + } +}) diff --git a/pages/happy-school/happy-school.json b/pages/happy-school/happy-school.json new file mode 100644 index 0000000..ca99e9f --- /dev/null +++ b/pages/happy-school/happy-school.json @@ -0,0 +1,9 @@ +{ + "navigationStyle": "custom", + "enablePullDownRefresh": true, + "backgroundTextStyle": "dark", + "backgroundColor": "#FFF9F0", + "usingComponents": { + "app-icon": "../../components/icon/icon" + } +} diff --git a/pages/happy-school/happy-school.wxml b/pages/happy-school/happy-school.wxml new file mode 100644 index 0000000..b1312b0 --- /dev/null +++ b/pages/happy-school/happy-school.wxml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + 快乐学堂 + + + + + + + + + 快乐学堂俱乐部 + + 终身学习 + 文化传承 + + + + 点击立即加入 + + + + + + + + + 精选活动 + + + 免费活动 + + + VIP活动 + + + SVIP活动 + + + + + + + + + + + + + + + + + {{item.price}} + + + + + {{item.title}} + + + + {{item.date}} + + + + + {{item.location}} + + + + + {{item.heat}} + + + + + + + + {{item.participants}}人已报名 + + + + + + + + + + 暂无课程 + + + + + 没有更多课程了 ~ + + + + + + + + + + + + + + + + + + + + + 加入快乐学堂群 + + + 活到老,学到老,快乐每一天 + + + + + + + + 长按二维码识别或保存 + + + + 保存二维码 + + + + diff --git a/pages/happy-school/happy-school.wxss b/pages/happy-school/happy-school.wxss new file mode 100644 index 0000000..45679dd --- /dev/null +++ b/pages/happy-school/happy-school.wxss @@ -0,0 +1,486 @@ +/* 快乐学堂页面样式 - 阳光橙黄主题 */ +page { + background: linear-gradient(180deg, #FFF4E6 0%, #FFF9F0 100%); +} + +.page-container { + min-height: 100vh; + background: linear-gradient(180deg, #FFF4E6 0%, #FFF9F0 100%); + position: relative; +} + +/* 固定导航栏容器 */ +.nav-container { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 999; + background: rgba(248, 249, 252, 0.75); + backdrop-filter: blur(20rpx) saturate(180%); + -webkit-backdrop-filter: blur(20rpx) saturate(180%); +} + +/* 状态栏 */ +.status-bar { + background: transparent; +} + +/* 导航栏 */ +.nav-bar { + display: flex; + align-items: center; + justify-content: center; + padding: 0 32rpx; + background: transparent; + position: relative; +} + +.nav-back { + position: absolute; + left: 32rpx; + top: 50%; + transform: translateY(-50%); + width: 80rpx; + height: 80rpx; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +.back-icon { + width: 48rpx; + height: 48rpx; +} + +.nav-title { + font-size: 36rpx; + font-weight: 700; + color: #1A1A1A; + letter-spacing: 1.8%; + line-height: 1; +} + +/* 内容滚动区域 */ +.content-scroll { + height: 100vh; + box-sizing: border-box; +} + +.content-scroll::-webkit-scrollbar { + display: none; +} + +/* 推广卡片 - 阳光渐变 */ +.city-group-card { + margin: 32rpx; + padding: 32rpx 40rpx; + min-height: 128rpx; + background: linear-gradient(135deg, + rgba(255, 244, 230, 0.6) 0%, + rgba(255, 249, 240, 0.6) 100%); + backdrop-filter: blur(16rpx) saturate(150%); + border: 2rpx solid rgba(255, 159, 67, 0.3); + border-radius: 48rpx; + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: 0 4rpx 20rpx rgba(255, 159, 67, 0.12); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.city-group-card:active { + transform: scale(0.98); + box-shadow: 0 2rpx 12rpx rgba(255, 159, 67, 0.18); +} + +.group-info { + flex: 1; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8rpx; + padding-right: 24rpx; +} + +.group-title { + font-size: 40rpx; + font-weight: 700; + color: #1A1A1A; + line-height: 1.4; + white-space: nowrap; +} + +.group-tags { + display: flex; + flex-direction: column; + gap: 4rpx; +} + +.tag-item { + font-size: 28rpx; + font-weight: 500; + color: #4A5565; + line-height: 1.4; + white-space: nowrap; +} + +.join-btn { + padding: 0 40rpx; + height: 88rpx; + background: linear-gradient(135deg, #FF9F43 0%, #FFBE76 100%); + border-radius: 100rpx; + display: flex; + align-items: center; + justify-content: center; + font-size: 32rpx; + font-weight: 700; + color: #fff; + white-space: nowrap; + flex-shrink: 0; + box-shadow: 0 6rpx 24rpx rgba(255, 159, 67, 0.4), + 0 3rpx 12rpx rgba(255, 159, 67, 0.3); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; +} + +.join-btn:active { + transform: scale(0.96); + box-shadow: 0 4rpx 16rpx rgba(255, 159, 67, 0.45); +} + +/* 活动标签切换 - 与首页风格一致但使用橙色系 */ +.tab-section { + padding: 32rpx 0; + background: transparent; + margin: 0 32rpx 32rpx; +} + +.tab-scroll { + width: 100%; + white-space: nowrap; +} + +.tab-list { + display: inline-flex; + gap: 20rpx; + padding: 0 4rpx; +} + +.tab-item { + padding: 20rpx 48rpx; + border-radius: 100rpx; + font-size: 32rpx; + font-weight: 700; + color: #6A7282; + background: rgba(255, 255, 255, 0.6); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; + flex-shrink: 0; +} + +.tab-item.active { + color: #fff; + background: linear-gradient(135deg, #FF9F43 0%, #FFBE76 100%); + box-shadow: 0 12rpx 24rpx rgba(255, 159, 67, 0.3); + transform: scale(1.02); +} + +/* 活动列表 */ +.activity-list { + padding: 0 32rpx; +} + +.activity-card { + background: #fff; + border-radius: 48rpx; + margin-bottom: 32rpx; + overflow: hidden; + box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.activity-image-wrap { + width: 100%; + height: 400rpx; + position: relative; + overflow: hidden; +} + +.activity-image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.activity-image-gradient { + width: 100%; + height: 100%; + background: linear-gradient(135deg, #FFF4E6 0%, #FFF9F0 100%); +} + +.like-badge { + position: absolute; + top: 32rpx; + right: 32rpx; + display: flex; + align-items: center; + gap: 12rpx; + padding: 12rpx 28rpx 12rpx 16rpx; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(8px); + border-radius: 100rpx; + box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1); +} + +.like-icon { + width: 36rpx; + height: 36rpx; +} + +.like-count { + font-size: 32rpx; + font-weight: 700; + color: #4A5565; +} + +.price-tag { + position: absolute; + bottom: 32rpx; + left: 32rpx; + padding: 12rpx 28rpx; + border-radius: 100rpx; + font-size: 32rpx; + font-weight: 700; + color: #fff; +} + +.price-tag.paid { + background: linear-gradient(135deg, #F97316 0%, #EA580C 100%); +} + +.price-tag.free { + background: linear-gradient(135deg, #4ADE80 0%, #16A34A 100%); +} + +.activity-info { + padding: 40rpx; +} + +.activity-title { + font-size: 40rpx; + font-weight: 700; + color: #1A1A1A; + margin-bottom: 24rpx; + display: block; +} + +.activity-meta { + display: flex; + flex-direction: column; + gap: 20rpx; + margin-bottom: 24rpx; +} + +.meta-row { + display: flex; + align-items: center; + justify-content: space-between; +} + +.meta-item { + display: flex; + align-items: center; + gap: 16rpx; +} + +.meta-icon { + width: 36rpx; + height: 36rpx; + opacity: 0.6; +} + +.meta-text { + font-size: 30rpx; + color: #4A5565; +} + +.heat-text { + color: #FF9F43; + font-weight: 700; +} + +.activity-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 24rpx; + border-top: 2rpx solid #F3F4F6; +} + +.participants { + display: flex; + align-items: center; + gap: 16rpx; +} + +.avatar-stack { + display: flex; +} + +.mini-avatar { + width: 56rpx; + height: 56rpx; + border-radius: 50%; + background: #F3F4F6; + border: 2rpx solid #fff; + margin-left: -12rpx; +} + +.mini-avatar:first-child { + margin-left: 0; +} + +.participant-text { + font-size: 28rpx; + color: #6A7282; +} + +.signup-btn { + width: 220rpx; + height: 80rpx; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #FF9F43 0%, #FFBE76 100%); + border-radius: 100rpx; + font-size: 30rpx; + font-weight: 700; + color: #fff; + box-shadow: 0 8rpx 16rpx rgba(255, 159, 67, 0.3); + white-space: nowrap; + flex-shrink: 0; +} + +.signup-btn.signed { + background: #9CA3AF; + box-shadow: none; +} + +/* 空状态 */ +.empty-state { + padding: 100rpx 0; + text-align: center; +} + +.empty-icon { + width: 200rpx; + height: 200rpx; + opacity: 0.5; +} + +.empty-text { + font-size: 32rpx; + color: #FFBE76; +} + +/* 二维码弹窗 */ +.qrcode-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 10000; + display: none; + align-items: center; + justify-content: center; +} + +.qrcode-modal.show { + display: flex; +} + +.modal-mask { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); +} + +.modal-content { + position: relative; + width: 600rpx; + background: #fff; + border-radius: 48rpx; + padding: 60rpx; + display: flex; + flex-direction: column; + align-items: center; +} + +.close-btn { + position: absolute; + top: 30rpx; + right: 30rpx; + width: 60rpx; + height: 60rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.close-icon { + width: 32rpx; + height: 32rpx; +} + +.modal-title { + font-size: 44rpx; + font-weight: 700; + margin-bottom: 12rpx; +} + +.modal-subtitle { + font-size: 28rpx; + color: #666; + margin-bottom: 40rpx; +} + +.qrcode-container { + width: 400rpx; + height: 400rpx; + border: 2rpx solid #eee; + border-radius: 24rpx; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 40rpx; +} + +.qrcode-image { + width: 360rpx; + height: 360rpx; +} + +.modal-tips { + font-size: 24rpx; + color: #999; + margin-bottom: 24rpx; +} + +.save-btn { + width: 100%; + height: 88rpx; + background: linear-gradient(135deg, #FF9F43 0%, #FFBE76 100%); + color: #fff; + border-radius: 44rpx; + display: flex; + align-items: center; + justify-content: center; + font-size: 32rpx; + font-weight: 700; +} diff --git a/pages/housekeeping-apply/housekeeping-apply.js b/pages/housekeeping-apply/housekeeping-apply.js new file mode 100644 index 0000000..ae4ce25 --- /dev/null +++ b/pages/housekeeping-apply/housekeeping-apply.js @@ -0,0 +1,260 @@ +// pages/housekeeping-apply/housekeeping-apply.js +// 家政保洁申请页面 +const api = require('../../utils/api') + +Page({ + data: { + statusBarHeight: 44, + navBarHeight: 44, + totalNavHeight: 88, + showForm: true, + applyStatus: 'none', + statusTitle: '', + statusDesc: '', + isReapply: false, + agreed: false, + formData: { + avatar: '', + realName: '', + gender: '', + age: '', + idCard: '', + city: '', + serviceArea: '', + serviceTypes: [], + workYears: '', + healthCert: '', + idFront: '', + idBack: '', + skillCert: '', + introduction: '', + phone: '' + }, + canSubmit: false + }, + + onLoad(options) { + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + const menuButton = wx.getMenuButtonBoundingClientRect() + const navBarHeight = menuButton.height + (menuButton.top - statusBarHeight) * 2 + const totalNavHeight = statusBarHeight + navBarHeight + + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight, + isReapply: options.isReapply === 'true' + }) + + this.checkApplyStatus() + }, + + goBack() { + wx.navigateBack() + }, + + async checkApplyStatus() { + const token = wx.getStorageSync('auth_token') + if (!token) { + this.setData({ applyStatus: 'none' }) + return + } + + try { + const res = await api.request('/housekeeping/apply') + if (res.success && res.data) { + const data = res.data + if (data.status === 'approved') { + this.setData({ + applyStatus: 'approved', + statusTitle: '申请已通过', + statusDesc: '恭喜您成为家政服务师!' + }) + } else if (data.status === 'pending') { + this.setData({ + applyStatus: 'pending', + statusTitle: '审核中', + statusDesc: '您的申请正在审核中,请耐心等待' + }) + } else if (data.status === 'rejected') { + this.setData({ + applyStatus: 'rejected', + statusTitle: '申请未通过', + statusDesc: data.rejectReason || '很抱歉,您的申请未通过审核' + }) + } + } + } catch (err) { + console.log('获取申请状态失败:', err) + this.setData({ applyStatus: 'none' }) + } + }, + + reapply() { + this.setData({ isReapply: true, applyStatus: 'none' }) + }, + + chooseAvatar() { + this.doChooseMedia('avatar') + }, + + uploadCert(e) { + const type = e.currentTarget.dataset.type + this.doChooseMedia(type) + }, + + doChooseMedia(field) { + wx.chooseMedia({ + count: 1, + mediaType: ['image'], + sourceType: ['album', 'camera'], + success: async (res) => { + const tempFilePath = res.tempFiles[0].tempFilePath + wx.showLoading({ title: '上传中...' }) + try { + const uploadRes = await api.uploadFile(tempFilePath, 'housekeeping') + if (uploadRes.success) { + const fieldMap = { + 'avatar': 'formData.avatar', + 'health': 'formData.healthCert', + 'idFront': 'formData.idFront', + 'idBack': 'formData.idBack', + 'skill': 'formData.skillCert' + } + this.setData({ [fieldMap[field]]: uploadRes.data.url }) + this.checkCanSubmit() + } + } catch (err) { + wx.showToast({ title: '上传失败', icon: 'none' }) + } finally { + wx.hideLoading() + } + } + }) + }, + + onInputChange(e) { + const field = e.currentTarget.dataset.field + this.setData({ [`formData.${field}`]: e.detail.value }) + this.checkCanSubmit() + }, + + selectGender(e) { + this.setData({ 'formData.gender': e.currentTarget.dataset.gender }) + this.checkCanSubmit() + }, + + toggleServiceType(e) { + const type = e.currentTarget.dataset.type + const serviceTypes = [...this.data.formData.serviceTypes] + const index = serviceTypes.indexOf(type) + if (index > -1) { + serviceTypes.splice(index, 1) + } else { + serviceTypes.push(type) + } + this.setData({ 'formData.serviceTypes': serviceTypes }) + this.checkCanSubmit() + }, + + toggleAgreement() { + this.setData({ agreed: !this.data.agreed }) + this.checkCanSubmit() + }, + + viewAgreement() { + wx.navigateTo({ url: '/pages/agreement/agreement?code=housekeeping_service' }) + }, + + checkCanSubmit() { + const { formData, agreed } = this.data + const idCardValid = /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(formData.idCard) + + const canSubmit = + formData.realName && + formData.gender && + formData.age && + formData.idCard && idCardValid && + formData.city && + formData.serviceArea && + formData.serviceTypes.length > 0 && + formData.workYears && + formData.healthCert && + formData.idFront && + formData.idBack && + formData.introduction && + formData.introduction.length >= 50 && + formData.phone && + formData.phone.length === 11 && + agreed + + this.setData({ canSubmit }) + }, + + async submitApply() { + if (!this.data.canSubmit) return + + const { formData } = this.data + + if (!/^1[3-9]\d{9}$/.test(formData.phone)) { + wx.showToast({ title: '请输入正确的手机号', icon: 'none' }) + return + } + + const age = parseInt(formData.age) + if (age < 18 || age > 60) { + wx.showToast({ title: '年龄需在18-60岁之间', icon: 'none' }) + return + } + + wx.showLoading({ title: '提交中...' }) + try { + const res = await api.request('/housekeeping/apply', { + method: 'POST', + data: { + avatar: formData.avatar, + realName: formData.realName, + gender: formData.gender, + age: age, + idCard: formData.idCard, + city: formData.city, + serviceArea: formData.serviceArea, + serviceTypes: formData.serviceTypes, + workYears: parseInt(formData.workYears), + healthCert: formData.healthCert, + idFront: formData.idFront, + idBack: formData.idBack, + skillCert: formData.skillCert, + introduction: formData.introduction, + phone: formData.phone + } + }) + + if (res.success || res.code === 0) { + wx.showToast({ title: '申请已提交', icon: 'success' }) + this.setData({ + applyStatus: 'pending', + statusTitle: '审核中', + statusDesc: '您的申请正在审核中,请耐心等待', + isReapply: false + }) + } else { + wx.showToast({ title: res.message || '提交失败', icon: 'none' }) + } + } catch (err) { + if (err.code === 404) { + wx.showModal({ + title: '提示', + content: '家政保洁服务即将开放,敬请期待!', + showCancel: false, + confirmColor: '#b06ab3' + }) + } else { + wx.showToast({ title: err.message || '提交失败', icon: 'none' }) + } + } finally { + wx.hideLoading() + } + } +}) diff --git a/pages/housekeeping-apply/housekeeping-apply.json b/pages/housekeeping-apply/housekeeping-apply.json new file mode 100644 index 0000000..a3e86ce --- /dev/null +++ b/pages/housekeeping-apply/housekeeping-apply.json @@ -0,0 +1,5 @@ +{ + "usingComponents": {}, + "navigationStyle": "custom", + "navigationBarTextStyle": "black" +} diff --git a/pages/housekeeping-apply/housekeeping-apply.wxml b/pages/housekeeping-apply/housekeeping-apply.wxml new file mode 100644 index 0000000..391aa19 --- /dev/null +++ b/pages/housekeeping-apply/housekeeping-apply.wxml @@ -0,0 +1,267 @@ + + + + + + + + 返回 + + 家政保洁 + + + + + + + + + + + + + + + + {{statusTitle}} + {{statusDesc}} + + + + + + + + + 个人照片 + * + + + + + + + 上传照片 + + + + + + + + + 基本信息 + + + + + 姓名 + * + + + + + + + + + 性别 + * + + + + + + + + + + + + + + 年龄 + * + + + + + + + + + 身份证号 + * + + + + + + + + + + + 服务信息 + + + + + 服务城市 + * + + + + + + + + + 服务区域 + * + + + + + + + + + 服务项目 + * + + + 日常保洁 + 深度清洁 + 开荒保洁 + 家电清洗 + 收纳整理 + 擦玻璃 + + + + + + 从业年限 + * + + + + + + + + + + + 资质证书 + + + + + 健康证 + * + + + + + + 上传健康证 + + + + + + + 身份证正面 + * + + + + + + 上传身份证正面 + + + + + + + 身份证反面 + * + + + + + + 上传身份证反面 + + + + + + + 技能证书 + + + + + + 上传技能证书(选填) + + + + + + + + + 个人介绍 + * + + + + + + {{formData.introduction.length || 0}}/500 + + + + + + + 联系方式 + + + + + 手机号 + * + + + + + + + + + + + + + + 我已阅读并同意 + 《家政服务协议》 + + + + + + + + + + + diff --git a/pages/housekeeping-apply/housekeeping-apply.wxss b/pages/housekeeping-apply/housekeeping-apply.wxss new file mode 100644 index 0000000..4b324c0 --- /dev/null +++ b/pages/housekeeping-apply/housekeeping-apply.wxss @@ -0,0 +1,299 @@ +/* 家政保洁申请页面样式 */ +.page-container { + min-height: 100vh; + background: linear-gradient(180deg, #E8C3D4 0%, #F5E6EC 100%); +} + +.nav-header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + background: linear-gradient(135deg, #E8C3D4 0%, #D4A5C9 100%); +} + +.nav-content { + display: flex; + align-items: center; + justify-content: space-between; + height: 44px; + padding: 0 16px; +} + +.nav-back { + display: flex; + align-items: center; + min-width: 60px; +} + +.back-icon { width: 20px; height: 20px; } +.back-text { font-size: 14px; color: #333; margin-left: 4px; } +.nav-title { font-size: 17px; font-weight: 600; color: #333; } +.nav-placeholder { min-width: 60px; } + +.content-scroll { + height: 100vh; + padding-bottom: env(safe-area-inset-bottom); +} + +.intro-section { padding: 16px; } + +.banner-card { + height: 160px; + border-radius: 16px; + overflow: hidden; + margin-bottom: 16px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +.banner-gradient { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.banner-title { font-size: 24px; font-weight: 600; color: #fff; margin-bottom: 8px; } +.banner-subtitle { font-size: 14px; color: rgba(255,255,255,0.9); } + +.info-card { + background: #fff; + border-radius: 16px; + padding: 20px; + margin-bottom: 16px; + box-shadow: 0 2px 8px rgba(0,0,0,0.05); +} + +.card-header { + display: flex; + align-items: center; + margin-bottom: 16px; +} + +.card-icon { + width: 32px; + height: 32px; + background: linear-gradient(135deg, #E8C3D4 0%, #D4A5C9 100%); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + margin-right: 12px; +} + +.card-icon image { width: 20px; height: 20px; } +.card-title { font-size: 18px; font-weight: 600; color: #333; } +.intro-text { font-size: 14px; color: #666; line-height: 1.8; } + +.service-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} + +.service-item { + background: #f8f8f8; + border-radius: 8px; + padding: 12px 8px; + text-align: center; +} + +.service-name { font-size: 13px; color: #333; } + +.advantage-list { padding: 0; } + +.advantage-item { + display: flex; + align-items: center; + padding: 8px 0; +} + +.advantage-dot { + width: 6px; + height: 6px; + background: linear-gradient(135deg, #b06ab3 0%, #4568dc 100%); + border-radius: 50%; + margin-right: 12px; +} + +.advantage-text { font-size: 14px; color: #666; } + +.apply-btn-area { padding: 24px 0; text-align: center; } + +.apply-btn { + width: 100%; + height: 48px; + background: linear-gradient(135deg, #b06ab3 0%, #4568dc 100%); + border-radius: 24px; + color: #fff; + font-size: 16px; + font-weight: 500; + border: none; + margin-bottom: 12px; +} + +.apply-tip { font-size: 13px; color: #999; } + +.apply-form { padding: 16px; } + +.status-card { + background: #fff; + border-radius: 16px; + padding: 40px 24px; + text-align: center; + margin-bottom: 16px; +} + +.status-icon { width: 80px; height: 80px; margin: 0 auto 16px; } +.status-icon image { width: 100%; height: 100%; } +.status-title { display: block; font-size: 18px; font-weight: 600; color: #333; margin-bottom: 8px; } +.status-desc { font-size: 14px; color: #666; line-height: 1.6; } + +.btn-secondary { + margin-top: 24px; + width: 160px; + height: 44px; + background: #fff; + border: 1px solid #b06ab3; + border-radius: 22px; + color: #b06ab3; + font-size: 15px; +} + +.form-content { + background: #fff; + border-radius: 16px; + padding: 24px 20px; +} + +.form-header { text-align: center; margin-bottom: 24px; } +.form-title { display: block; font-size: 20px; font-weight: 600; color: #333; margin-bottom: 8px; } +.form-subtitle { font-size: 14px; color: #999; } + +.form-section { margin-bottom: 24px; } +.section-header { display: flex; align-items: center; margin-bottom: 16px; } +.section-title { font-size: 16px; font-weight: 600; color: #333; } +.required { color: #ff4d4f; margin-left: 4px; } + +.avatar-upload-area { display: flex; justify-content: center; margin-bottom: 8px; } + +.avatar-circle { + width: 100px; + height: 100px; + border-radius: 50%; + background: #f5f5f5; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} + +.avatar-image { width: 100%; height: 100%; } +.upload-placeholder { text-align: center; } +.camera-icon { width: 32px; height: 32px; margin-bottom: 4px; } +.upload-text { font-size: 12px; color: #999; } + +.form-item { margin-bottom: 16px; } +.item-label-row { display: flex; align-items: center; margin-bottom: 8px; } +.item-label { font-size: 14px; color: #333; } +.input-wrapper { background: #f8f8f8; border-radius: 8px; padding: 0 12px; } +.item-input { width: 100%; height: 44px; font-size: 14px; color: #333; } + +.gender-options { display: flex; gap: 12px; } + +.gender-btn { + flex: 1; + height: 44px; + background: #f8f8f8; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + color: #666; +} + +.gender-btn.active { + background: linear-gradient(135deg, #E8C3D4 0%, #D4A5C9 100%); + color: #333; + font-weight: 500; +} + +.service-types { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.service-btn { + padding: 8px 16px; + background: #f8f8f8; + border-radius: 20px; + font-size: 13px; + color: #666; +} + +.service-btn.active { + background: linear-gradient(135deg, #E8C3D4 0%, #D4A5C9 100%); + color: #333; +} + +.cert-upload { + width: 100%; + height: 120px; + background: #f8f8f8; + border-radius: 8px; + border: 1px dashed #ddd; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.cert-image { width: 100%; height: 100%; } +.cert-placeholder { text-align: center; } +.upload-icon { width: 40px; height: 40px; margin-bottom: 8px; } + +.textarea-wrapper { background: #f8f8f8; border-radius: 8px; padding: 12px; } +.intro-textarea { width: 100%; height: 120px; font-size: 14px; color: #333; line-height: 1.6; } +.textarea-footer { display: flex; justify-content: flex-end; margin-top: 8px; } +.char-count { font-size: 12px; color: #999; } + +.agreement-row { display: flex; align-items: center; margin: 24px 0; } + +.checkbox { + width: 20px; + height: 20px; + border: 1px solid #ddd; + border-radius: 4px; + margin-right: 8px; + display: flex; + align-items: center; + justify-content: center; +} + +.checkbox.checked { + background: linear-gradient(135deg, #b06ab3 0%, #4568dc 100%); + border-color: transparent; +} + +.check-icon { width: 14px; height: 14px; } +.normal-text { font-size: 13px; color: #666; } +.link-text { font-size: 13px; color: #b06ab3; } + +.submit-btn { + width: 100%; + height: 48px; + background: linear-gradient(135deg, #b06ab3 0%, #4568dc 100%); + border-radius: 24px; + color: #fff; + font-size: 16px; + font-weight: 500; + border: none; +} + +.submit-btn.disabled { opacity: 0.5; } +.bottom-placeholder { height: 40px; } diff --git a/pages/index/index.js b/pages/index/index.js new file mode 100644 index 0000000..964ce91 --- /dev/null +++ b/pages/index/index.js @@ -0,0 +1,1586 @@ +// pages/index/index.js +// 首页 - 角色列表展示(卡片滑动) + +const app = getApp() +const api = require('../../utils/api') +const util = require('../../utils/util') +const config = require('../../config/index') +const proactiveMessage = require('../../utils/proactiveMessage') + +// 获取静态资源基础URL(去掉/api后缀) +const getStaticBaseUrl = () => { + const apiUrl = config.API_BASE_URL + return apiUrl.replace(/\/api$/, '') +} + +// 好友故事数据 - 将在加载角色后动态更新 +let STORY_USERS = [] + +Page({ + data: { + statusBarHeight: 44, + navHeight: 96, + + // 角色数据 + profiles: [], + currentIndex: 0, + loading: true, + error: null, + + // 故事用户 + storyUsers: STORY_USERS, + + // 首页 Banner + homeBanner: '', + bannerHeight: 240, + + // 交互状态 + likedProfiles: {}, + unlockedProfiles: {}, + showVipModal: false, + + // 爱心弹窗 + showHeartPopup: false, + currentCharacter: null, + heartCount: 0, + heartPackages: [ + { id: 1, hearts: 10, price: 6, tag: '' }, + { id: 2, hearts: 30, price: 18, tag: '热门' }, + { id: 3, hearts: 50, price: 28, tag: '' }, + { id: 4, hearts: 100, price: 50, tag: '超值' }, + { id: 5, hearts: 200, price: 98, tag: '' }, + { id: 6, hearts: 500, price: 238, tag: '最划算' } + ], + selectedHeartPackage: 1, // 默认选中第二个(热门) + purchasing: false, + + // 未读消息数 + totalUnread: 0, + + // 滑动相关 + startX: 0, + startY: 0, + offsetX: 0, + rotation: 0, + swipeDirection: '', + + // 分享配置 + shareConfig: null, + + // 注册奖励 + showRegistrationReward: false, + registrationRewardAmount: 0, + claiming: false, + auditStatus: 0, + + // GF100 弹窗 + showGf100Popup: false, + gf100ImageUrl: '', + + // 解锁配置 + unlockHeartsCost: 500 // 默认解锁爱心成本 + }, + + async onLoad() { + // 获取系统信息 + const { statusBarHeight, navHeight, auditStatus } = app.globalData + this.setData({ statusBarHeight, navHeight, auditStatus }) + + // 如果是审核状态,重定向到文娱页面 + if (auditStatus === 1) { + wx.switchTab({ + url: '/pages/entertainment/entertainment' + }) + return + } + + // 加载首页素材 (Banner等) + this.loadHomeAssets() + + // 加载分享配置 + this.loadShareConfig() + + // 加载角色列表 + this.loadCharacters() + + // 加载用户爱心余额 + this.loadHeartBalance() + + // 加载解锁配置 + this.loadUnlockConfig() + }, + + /** + * 加载解锁配置 + */ + async loadUnlockConfig() { + try { + // 获取当前列表中的第一个角色ID用于查询配置(配置是全局的,任一ID即可) + const charId = this.data.profiles[0]?.id || 'default' + const res = await api.chat.getQuota(charId) + + if (res.success && res.data && res.data.unlock_config) { + const cost = res.data.unlock_config.hearts_cost + if (typeof cost === 'number') { + this.setData({ + unlockHeartsCost: cost + }) + console.log('[index] 已从后端同步解锁成本:', cost) + } + } + } catch (err) { + console.log('[index] 加载解锁配置失败,使用默认值', err) + } + }, + + /** + * 加载分享配置 + */ + async loadShareConfig() { + try { + const res = await api.promotion.getShareConfig('index') + if (res.success && res.data) { + // 处理图片URL,确保是完整路径 + const shareConfig = { + ...res.data, + imageUrl: res.data.imageUrl ? util.getFullImageUrl(res.data.imageUrl) : '' + } + this.setData({ + shareConfig + }) + } + } catch (error) { + console.error('加载分享配置失败:', error) + } + }, + + /** + * 处理图片URL,如果是相对路径则拼接域名,并设置清晰度为85 + */ + processImageUrl(url) { + return util.getFullImageUrl(url) + }, + + /** + * 轮播图图片加载完成,自适应高度 + */ + onBannerLoad(e) { + const { width, height } = e.detail; + const sysInfo = wx.getSystemInfoSync(); + // 减去左右padding (32rpx * 2) + const swiperWidth = sysInfo.windowWidth - (32 * 2 / 750 * sysInfo.windowWidth); + const ratio = width / height; + const bannerHeight = swiperWidth / ratio; + const bannerHeightRpx = bannerHeight * (750 / sysInfo.windowWidth); + + this.setData({ + bannerHeight: bannerHeightRpx + }); + }, + + /** + * 加载首页素材 + */ + async loadHomeAssets() { + try { + const res = await api.pageAssets.getAssets('banners') + console.log('首页素材 API响应:', res) + + if (res.success && res.data) { + // 优先使用 home_banner,其次使用 companion_banner + const bannerUrl = res.data.home_banner || res.data.companion_banner + if (bannerUrl) { + this.setData({ + homeBanner: this.processImageUrl(bannerUrl) + }) + console.log('已加载首页Banner') + } + } + } catch (err) { + console.error('加载首页素材失败', err) + } + }, + + onShow() { + // 隐藏默认tabbar + wx.hideTabBar({ animation: false }) + + const app = getApp() + this.setData({ + auditStatus: app.globalData.auditStatus + }) + + // 如果是审核状态,重定向到文娱页面 + if (app.globalData.auditStatus === 1) { + wx.switchTab({ + url: '/pages/entertainment/entertainment' + }) + return + } + + // 刷新爱心余额 + this.loadHeartBalance() + + // 加载未读消息数 + this.loadUnreadCount() + + // 检查AI角色主动推送消息 + this.checkProactiveMessages() + + // 检查注册奖励 + this.checkRegistrationReward() + + // 检查 GF100 弹窗 + this.checkGf100Popup() + }, + + onPullDownRefresh() { + Promise.all([ + this.loadCharacters(), + this.loadHeartBalance() + ]).then(() => { + wx.stopPullDownRefresh() + }) + }, + + /** + * 加载用户爱心值 + * 使用 /api/auth/me 接口,该接口从 im_users.grass_balance 读取余额 + */ + async loadHeartBalance() { + try { + const token = wx.getStorageSync(config.STORAGE_KEYS.TOKEN) + if (!token) { + this.setData({ heartCount: 0 }) + return + } + + // 使用 auth.getCurrentUser 接口,该接口返回 grass_balance 字段 + const res = await api.auth.getCurrentUser() + if (res.success && res.data) { + this.setData({ + heartCount: res.data.grass_balance || 0 + }) + console.log('[index] 爱心值加载成功:', res.data.grass_balance) + } + } catch (err) { + console.log('加载爱心值失败', err) + } + }, + + /** + * 加载未读消息数 + * 包含会话未读数 + 主动推送消息数 + */ + async loadUnreadCount() { + if (!app.globalData.isLoggedIn) { + this.setData({ totalUnread: 0 }) + return + } + + try { + // 并行获取会话列表和主动推送消息 + const [convRes, proactiveRes] = await Promise.all([ + api.chat.getConversations(), + api.proactiveMessage.getPending() + ]) + + let totalUnread = 0 + + // 计算会话未读数 + if (convRes.success && convRes.data) { + totalUnread = convRes.data.reduce((sum, conv) => sum + (conv.unread_count || 0), 0) + } + + // 加上主动推送消息数 + if (proactiveRes.success && proactiveRes.data && Array.isArray(proactiveRes.data)) { + totalUnread += proactiveRes.data.length + console.log('[index] 主动推送消息数:', proactiveRes.data.length) + } + + this.setData({ totalUnread }) + console.log('[index] 总未读消息数:', totalUnread) + } catch (err) { + console.log('获取未读消息数失败', err) + this.setData({ totalUnread: 0 }) + } + }, + + /** + * 检查AI角色主动推送消息 + * 注意:未读数已在 loadUnreadCount 中计算,不在首页显示Toast提示 + */ + async checkProactiveMessages() { + if (!app.globalData.isLoggedIn) { + return + } + + try { + const messages = await proactiveMessage.checkAndShowMessages({ + force: true // 首次进入强制检查 + }) + + console.log('[index] 主动推送消息检查完成,消息数:', messages.length) + } catch (err) { + console.log('[index] 检查主动推送消息失败', err) + } + }, + + /** + * 加载角色列表 + */ + async loadCharacters() { + this.setData({ loading: true, error: null }) + + try { + console.log('[index] 开始加载角色列表...') + const res = await api.character.getRandom(10) + console.log('[index] API响应:', JSON.stringify(res)) + + // 兼容两种返回格式: + // 1. { code: 0, data: { list: [...] } } - 新格式 + // 2. { success: true, data: [...] } - 旧格式 + let characters = [] + if (res.code === 0 && res.data) { + characters = res.data.list || res.data + if (!Array.isArray(characters)) { + characters = [] + } + } else if (res.success && res.data) { + characters = Array.isArray(res.data) ? res.data : (res.data.list || []) + } + + console.log('[index] 解析到角色数量:', characters.length) + + if (characters.length > 0) { + // 转换数据格式:后端格式 -> 前端格式 + const profiles = characters.map(char => this.transformCharacter(char)) + + // 更新好友故事区域(取前8个角色) + const storyUsers = profiles.slice(0, 8).map(p => ({ + id: p.id, + name: p.name, + img: p.avatar + })) + + this.setData({ + profiles, + storyUsers, + currentIndex: 0, + loading: false + }) + + console.log('[index] 角色加载成功,数量:', profiles.length) + } else { + console.log('[index] 没有角色数据') + this.setData({ + loading: false, + profiles: [], + currentIndex: 0, + error: '暂无角色数据' + }) + } + } catch (err) { + console.error('[index] 加载角色失败', err) + this.setData({ + loading: false, + profiles: [], + currentIndex: 0, + error: err.message || '加载失败' + }) + // 不显示toast,让用户看到空状态页面 + } + }, + + /** + * 转换角色数据格式 + * @param {object} char - 后端角色数据 + */ + transformCharacter(char) { + // 静态资源基础URL + const staticBaseUrl = getStaticBaseUrl() + + // 转换照片路径为完整URL + const convertPhotoUrl = (url) => { + if (!url) return '' + // 如果已经是完整URL,直接返回 + if (url.startsWith('http://') || url.startsWith('https://')) { + return url + } + // 如果是相对路径,拼接基础URL + if (url.startsWith('/characters/')) { + return staticBaseUrl + url + } + return url + } + + // 头像URL(用于推荐栏圆形小头像)- 优先使用 avatar/logo + const avatarUrl = convertPhotoUrl(char.avatar || char.logo || char.image) + + // 宣传图URL(用于卡片大图)- 优先使用 promoImage + const promoImageUrl = convertPhotoUrl(char.promoImage || char.promo_image || char.avatar || char.image) + + return { + id: char.id, + name: char.name || '未知', + height: char.height || '', + location: char.location || char.province || '', + occupation: char.occupation || '', + hobbies: char.hobbies || '', + tags: char.tags || [], + bio: char.bio || char.description || char.self_introduction || '', + image: promoImageUrl, // 卡片大图使用宣传图 + avatar: avatarUrl, // 小头像使用头像 + gender: char.gender || 'female', + age: char.age || '', + voiceId: char.voice_id, + isVipOnly: char.is_vip_only || false, + // 开场白音频URL + greetingAudioUrl: char.greetingAudioUrl || char.greeting_audio_url || '' + } + }, + + // ==================== 滑动交互 ==================== + + onTouchStart(e) { + this.setData({ + startX: e.touches[0].clientX, + startY: e.touches[0].clientY, + swipeDirection: '' + }) + }, + + onTouchMove(e) { + const moveX = e.touches[0].clientX - this.data.startX + const rotation = moveX * 0.05 + + this.setData({ + offsetX: moveX, + rotation: Math.max(-15, Math.min(15, rotation)) + }) + }, + + onTouchEnd(e) { + const threshold = 80 + const { offsetX } = this.data + + if (offsetX > threshold) { + // 右滑 - 喜欢 + this.handleSwipeLike() + } else if (offsetX < -threshold) { + // 左滑 - 跳过 + this.handlePass() + } else { + // 重置位置 + this.setData({ + offsetX: 0, + rotation: 0 + }) + } + }, + + /** + * 处理喜欢操作 + */ + async handleSwipeLike() { + const { currentIndex, profiles, likedProfiles } = this.data + + if (currentIndex >= profiles.length) return + + const currentProfile = profiles[currentIndex] + const currentId = currentProfile.id + + // 更新本地状态 + const newLiked = { ...likedProfiles } + newLiked[currentId] = true + + this.setData({ + swipeDirection: 'swipe-right', + likedProfiles: newLiked + }) + + // 调用API(静默操作,不显示提示) + try { + await api.character.toggleLike(currentId) + } catch (err) { + console.log('喜欢操作失败', err) + } + + // 移动到下一张 + setTimeout(() => { + this.moveToNext() + }, 300) + }, + + /** + * 处理跳过操作 + */ + handlePass() { + this.setData({ swipeDirection: 'swipe-left' }) + + setTimeout(() => { + this.moveToNext() + }, 300) + }, + + /** + * 移动到下一张卡片 + */ + moveToNext() { + const { currentIndex, profiles } = this.data + const nextIndex = currentIndex + 1 + + // 如果快到末尾,加载更多 + if (nextIndex >= profiles.length - 2) { + this.loadMoreCharacters() + } + + this.setData({ + currentIndex: nextIndex, + offsetX: 0, + rotation: 0, + swipeDirection: '' + }) + }, + + /** + * 加载更多角色 + * 传递已显示的角色ID,避免重复 + */ + async loadMoreCharacters() { + try { + // 获取已显示的角色ID列表 + const existingIds = this.data.profiles.map(p => p.id).filter(id => id) + + const res = await api.character.getRandom(6, { excludeIds: existingIds }) + + // 兼容两种返回格式 + let characters = [] + if (res.code === 0 && res.data) { + characters = res.data.list || res.data + } else if (res.success && res.data) { + characters = Array.isArray(res.data) ? res.data : (res.data.list || []) + } + + if (characters.length > 0) { + // 再次过滤,确保不重复 + const existingIdSet = new Set(existingIds) + const filteredCharacters = characters.filter(char => !existingIdSet.has(char.id)) + + if (filteredCharacters.length > 0) { + const newProfiles = filteredCharacters.map(char => this.transformCharacter(char)) + + this.setData({ + profiles: [...this.data.profiles, ...newProfiles] + }) + } + } + } catch (err) { + console.log('加载更多失败', err) + } + }, + + // ==================== 按钮操作 ==================== + + /** + * 切换喜欢状态 - 显示爱心套餐弹窗 + */ + onToggleLike() { + const { currentIndex, profiles } = this.data + + if (currentIndex >= profiles.length) return + + const currentProfile = profiles[currentIndex] + + // 保存当前角色信息并显示弹窗 + this.setData({ + showHeartPopup: true, + currentCharacter: currentProfile + }) + }, + + /** + * 播放语音(优先使用预录制的开场白音频) + */ + async onPlayVoice() { + const { currentIndex, profiles } = this.data + + if (currentIndex >= profiles.length) return + + const currentProfile = profiles[currentIndex] + + // 优先使用预录制的开场白音频 + const greetingAudioUrl = currentProfile.greetingAudioUrl || currentProfile.greeting_audio_url + + if (greetingAudioUrl) { + // 处理相对路径,拼接完整URL + let audioUrl = greetingAudioUrl + if (audioUrl.startsWith('/')) { + audioUrl = getStaticBaseUrl() + audioUrl + } + + console.log('[index] 播放开场白音频:', audioUrl) + + // 停止之前的音频 + if (this.audioContext) { + try { + this.audioContext.stop() + this.audioContext.destroy() + } catch (e) {} + this.audioContext = null + } + + // 先检查音频文件是否存在 + wx.request({ + url: audioUrl, + method: 'HEAD', + success: (res) => { + if (res.statusCode === 200) { + // 文件存在,播放音频 + this.playAudioFile(audioUrl) + } else { + console.log('[index] 音频文件不存在:', res.statusCode) + wx.showToast({ title: '该角色暂无独白音频', icon: 'none' }) + } + }, + fail: (err) => { + console.log('[index] 检查音频文件失败:', err) + // 即使HEAD请求失败,也尝试播放(有些服务器不支持HEAD) + this.playAudioFile(audioUrl) + } + }) + return + } + + // 没有预录制音频,提示用户 + wx.showToast({ + title: '该角色暂无独白音频', + icon: 'none', + duration: 2000 + }) + }, + + /** + * 播放音频文件 + */ + playAudioFile(audioUrl) { + // 显示播放中提示 + wx.showToast({ + title: '播放独白中...', + icon: 'none', + duration: 5000 + }) + + // 创建音频上下文 + const innerAudioContext = wx.createInnerAudioContext() + this.audioContext = innerAudioContext + + // 配置音频属性 + innerAudioContext.src = audioUrl + innerAudioContext.volume = 1.0 + innerAudioContext.obeyMuteSwitch = false // 不受系统静音开关影响 + innerAudioContext.autoplay = false + + // 确保在 iOS 上即使在静音模式下也能播放(双重保险) + if (wx.setInnerAudioOption) { + wx.setInnerAudioOption({ + obeyMuteSwitch: false, + speakerOn: true + }) + } + + innerAudioContext.onCanplay(() => { + console.log('[index] 音频可以播放, duration:', innerAudioContext.duration) + }) + + innerAudioContext.onPlay(() => { + console.log('[index] 音频开始播放, volume:', innerAudioContext.volume) + }) + + innerAudioContext.onTimeUpdate(() => { + // 每秒打印一次进度 + const currentTime = Math.floor(innerAudioContext.currentTime) + if (currentTime !== this._lastLogTime) { + console.log('[index] 播放进度:', currentTime, '/', Math.floor(innerAudioContext.duration || 0)) + this._lastLogTime = currentTime + } + }) + + innerAudioContext.onError((err) => { + console.error('[index] 音频播放错误:', JSON.stringify(err)) + wx.hideToast() + let errMsg = '播放失败' + if (err.errCode === 10001 || err.errCode === -1) { + errMsg = '音频文件不存在' + } else if (err.errCode === 10002) { + errMsg = '网络错误' + } else if (err.errCode === 10003 || err.errCode === 10004) { + errMsg = '音频格式不支持' + } + wx.showToast({ title: errMsg, icon: 'none' }) + }) + + innerAudioContext.onEnded(() => { + console.log('[index] 音频播放结束') + wx.hideToast() + }) + + // 延迟播放 + setTimeout(() => { + console.log('[index] 调用 play()') + innerAudioContext.play() + }, 100) + }, + + /** + * 选择角色(进入详情) + */ + onSelectCharacter() { + const { currentIndex, profiles } = this.data + + if (currentIndex >= profiles.length) return + + const profile = profiles[currentIndex] + + wx.navigateTo({ + url: `/pages/character-detail/character-detail?id=${profile.id}` + }) + }, + + /** + * 点击卡片进入详情 + */ + onCardTap() { + // 如果有滑动偏移,不触发点击 + if (Math.abs(this.data.offsetX) > 10) { + return + } + + this.onSelectCharacter() + }, + + /** + * 刷新角色列表 + */ + onRefresh() { + this.loadCharacters() + + wx.showToast({ + title: '已为您换一批', + icon: 'success' + }) + }, + + // ==================== VIP弹窗 ==================== + + closeVipModal() { + this.setData({ showVipModal: false }) + }, + + preventBubble() { + // 阻止事件冒泡 + }, + + preventTouchMove() { + // 阻止滚动穿透 + }, + + // ==================== 爱心弹窗 ==================== + + /** + * 显示爱心弹窗 + */ + showHeartPopup() { + this.setData({ showHeartPopup: true }) + }, + + /** + * 关闭爱心弹窗 + */ + closeHeartPopup() { + this.setData({ + showHeartPopup: false, + currentCharacter: null + }) + }, + + /** + * 选择爱心套餐 + */ + selectHeartPackage(e) { + const index = e.currentTarget.dataset.index + this.setData({ selectedHeartPackage: index }) + }, + + /** + * 购买爱心 + * 使用 /api/payment/unified-order 接口 + * 测试模式下返回 testMode: true,订单直接完成,无需调用微信支付 + */ + async buyHearts() { + const token = wx.getStorageSync(config.STORAGE_KEYS.TOKEN) + if (!token) { + wx.showToast({ title: '请先登录', icon: 'none' }) + setTimeout(() => { + wx.navigateTo({ url: '/pages/login/login' }) + }, 1500) + return + } + + const selectedPackage = this.data.heartPackages[this.data.selectedHeartPackage] + if (!selectedPackage) { + wx.showToast({ title: '请选择套餐', icon: 'none' }) + return + } + + this.setData({ purchasing: true }) + + try { + wx.showLoading({ title: '创建订单中...' }) + + // 调用统一支付订单接口 + const res = await api.payment.createUnifiedOrder({ + type: 'recharge', + amount: selectedPackage.price, + rechargeValue: selectedPackage.hearts + }) + + wx.hideLoading() + + console.log('[buyHearts] API返回:', JSON.stringify(res)) + + if (res.success) { + // 检查是否为测试模式(兼容多种判断方式) + const isTestMode = res.testMode || res.test_mode || res.data?.testMode || res.data?.test_mode || + // 如果 payParams.package 包含 mock_prepay,也认为是测试模式 + (res.payParams?.package && res.payParams.package.includes('mock_prepay')) + + if (isTestMode) { + // 测试模式:订单已直接完成,无需调用微信支付 + wx.showToast({ title: '购买成功', icon: 'success' }) + await this.loadHeartBalance() + this.closeHeartPopup() + } else { + // 正式模式:调用微信支付 + const payParams = res.payParams || res.pay_params || res.data?.payParams || res.data?.pay_params + + if (!payParams || !payParams.timeStamp) { + wx.showToast({ title: '支付参数错误', icon: 'none' }) + return + } + + wx.requestPayment({ + timeStamp: payParams.timeStamp, + nonceStr: payParams.nonceStr, + package: payParams.package, + signType: payParams.signType || 'RSA', + paySign: payParams.paySign, + success: async () => { + wx.showToast({ title: '购买成功', icon: 'success' }) + await this.loadHeartBalance() + this.closeHeartPopup() + }, + fail: (err) => { + if (err.errMsg !== 'requestPayment:fail cancel') { + wx.showToast({ title: '支付失败', icon: 'none' }) + } + } + }) + } + } else { + wx.showToast({ title: res.message || '创建订单失败', icon: 'none' }) + } + } catch (err) { + wx.hideLoading() + console.error('购买爱心失败', err) + wx.showToast({ title: '网络错误,请重试', icon: 'none' }) + } finally { + this.setData({ purchasing: false }) + } + }, + + /** + * 分享解锁 + */ + onShareUnlock() { + wx.showToast({ + title: '分享功能开发中', + icon: 'none' + }) + // TODO: 实现分享解锁逻辑 + }, + + /** + * 爱心兑换解锁 + */ + async onExchangeHearts() { + const token = wx.getStorageSync(config.STORAGE_KEYS.TOKEN) + if (!token) { + wx.showToast({ title: '请先登录', icon: 'none' }) + setTimeout(() => { + wx.navigateTo({ url: '/pages/login/login' }) + }, 1500) + return + } + + const { currentCharacter, heartCount, unlockHeartsCost } = this.data + if (!currentCharacter) return + + // 检查爱心值,不足时提示并跳转充值页面 + if (heartCount < unlockHeartsCost) { + wx.showToast({ title: '爱心值不足,去充值', icon: 'none' }) + setTimeout(() => { + this.setData({ showHeartPopup: false }) + wx.navigateTo({ url: '/pages/recharge/recharge' }) + }, 1500) + return + } + + this.setData({ purchasing: true }) + + try { + wx.showLoading({ title: '兑换中...' }) + + const res = await api.character.unlock({ + character_id: currentCharacter.id, + unlock_type: 'hearts' + }) + + wx.hideLoading() + + if (res.success || res.code === 0) { + wx.showToast({ title: '解锁成功', icon: 'success' }) + + // 更新爱心余额(优先使用后端返回的余额,否则本地扣减) + const newBalance = res.data?.remaining_hearts ?? (this.data.heartCount - unlockHeartsCost) + this.setData({ + heartCount: newBalance + }) + + // 记录已解锁 + const newUnlocked = { ...this.data.unlockedProfiles } + newUnlocked[currentCharacter.id] = true + this.setData({ + unlockedProfiles: newUnlocked, + showHeartPopup: false, + currentCharacter: null + }) + + // 延迟后跳转到聊天页面 + setTimeout(() => { + wx.navigateTo({ + url: `/pages/chat-detail/chat-detail?id=${currentCharacter.id}&name=${encodeURIComponent(currentCharacter.name)}` + }) + }, 1000) + } else { + wx.showToast({ title: res.message || '兑换失败', icon: 'none' }) + } + } catch (err) { + wx.hideLoading() + console.error('爱心兑换失败', err) + wx.showToast({ title: '网络错误,请重试', icon: 'none' }) + } finally { + this.setData({ purchasing: false }) + } + }, + + /** + * 直接购买解锁(9.9元) + * 使用 /api/payment/unified-order 接口 + * 测试模式下返回 testMode: true,订单直接完成,无需调用微信支付 + */ + async onPurchaseDirect() { + const token = wx.getStorageSync(config.STORAGE_KEYS.TOKEN) + if (!token) { + wx.showToast({ title: '请先登录', icon: 'none' }) + setTimeout(() => { + wx.navigateTo({ url: '/pages/login/login' }) + }, 1500) + return + } + + const { currentCharacter } = this.data + if (!currentCharacter) return + + this.setData({ purchasing: true }) + + try { + wx.showLoading({ title: '创建订单中...' }) + + // 调用统一支付订单接口 + const res = await api.payment.createUnifiedOrder({ + type: 'character_unlock', + character_id: currentCharacter.id, + amount: 9.9 + }) + + wx.hideLoading() + + console.log('[onPurchaseDirect] API返回:', JSON.stringify(res)) + + if (res.success) { + // 检查是否为测试模式(兼容多种判断方式) + const isTestMode = res.testMode || res.test_mode || res.data?.testMode || res.data?.test_mode || + // 如果 payParams.package 包含 mock_prepay,也认为是测试模式 + (res.payParams?.package && res.payParams.package.includes('mock_prepay')) + + if (isTestMode) { + // 测试模式:订单已直接完成,无需调用微信支付 + wx.showToast({ title: '购买成功', icon: 'success' }) + + // 记录已解锁 + const newUnlocked = { ...this.data.unlockedProfiles } + newUnlocked[currentCharacter.id] = true + this.setData({ + unlockedProfiles: newUnlocked, + showHeartPopup: false, + currentCharacter: null + }) + + // 延迟后跳转到聊天页面 + setTimeout(() => { + wx.navigateTo({ + url: `/pages/chat-detail/chat-detail?id=${currentCharacter.id}&name=${encodeURIComponent(currentCharacter.name)}` + }) + }, 1000) + } else { + // 正式模式:调用微信支付 + const payParams = res.payParams || res.pay_params || res.data?.payParams || res.data?.pay_params + + if (!payParams || !payParams.timeStamp) { + wx.showToast({ title: '支付参数错误', icon: 'none' }) + return + } + + wx.requestPayment({ + timeStamp: payParams.timeStamp, + nonceStr: payParams.nonceStr, + package: payParams.package, + signType: payParams.signType || 'RSA', + paySign: payParams.paySign, + success: () => { + wx.showToast({ title: '购买成功', icon: 'success' }) + + // 记录已解锁 + const newUnlocked = { ...this.data.unlockedProfiles } + newUnlocked[currentCharacter.id] = true + this.setData({ + unlockedProfiles: newUnlocked, + showHeartPopup: false, + currentCharacter: null + }) + + // 延迟后跳转到聊天页面 + setTimeout(() => { + wx.navigateTo({ + url: `/pages/chat-detail/chat-detail?id=${currentCharacter.id}&name=${encodeURIComponent(currentCharacter.name)}` + }) + }, 1000) + }, + fail: (err) => { + if (err.errMsg !== 'requestPayment:fail cancel') { + wx.showToast({ title: '支付失败', icon: 'none' }) + } + } + }) + } + } else { + wx.showToast({ title: res.message || '创建订单失败', icon: 'none' }) + } + } catch (err) { + wx.hideLoading() + console.error('购买解锁失败', err) + wx.showToast({ title: '网络错误,请重试', icon: 'none' }) + } finally { + this.setData({ purchasing: false }) + } + }, + + /** + * 购买并解锁角色聊天(套餐购买) + * 使用 /api/payment/unified-order 接口 + * 测试模式下返回 testMode: true,订单直接完成,无需调用微信支付 + */ + async buyAndUnlock() { + const token = wx.getStorageSync(config.STORAGE_KEYS.TOKEN) + if (!token) { + wx.showToast({ title: '请先登录', icon: 'none' }) + setTimeout(() => { + wx.navigateTo({ url: '/pages/login/login' }) + }, 1500) + return + } + + const { currentCharacter, heartPackages, selectedHeartPackage } = this.data + if (!currentCharacter) return + + const selectedPackage = heartPackages[selectedHeartPackage] + + if (!selectedPackage) { + wx.showToast({ title: '请选择套餐', icon: 'none' }) + return + } + + this.setData({ purchasing: true }) + + try { + wx.showLoading({ title: '创建订单中...' }) + + // 调用统一支付订单接口 + const res = await api.payment.createUnifiedOrder({ + type: 'character_unlock', + character_id: currentCharacter.id, + amount: selectedPackage.price + }) + + wx.hideLoading() + + console.log('[buyAndUnlock] API返回:', JSON.stringify(res)) + + if (res.success) { + // 检查是否为测试模式(兼容多种判断方式) + const isTestMode = res.testMode || res.test_mode || res.data?.testMode || res.data?.test_mode || + // 如果 payParams.package 包含 mock_prepay,也认为是测试模式 + (res.payParams?.package && res.payParams.package.includes('mock_prepay')) + + if (isTestMode) { + // 测试模式:订单已直接完成,无需调用微信支付 + wx.showToast({ title: '购买成功', icon: 'success' }) + + // 刷新爱心余额 + this.loadHeartBalance() + + // 记录已解锁 + const newUnlocked = { ...this.data.unlockedProfiles } + newUnlocked[currentCharacter.id] = true + this.setData({ + unlockedProfiles: newUnlocked, + showHeartPopup: false, + currentCharacter: null + }) + + // 延迟后跳转到聊天页面 + setTimeout(() => { + wx.navigateTo({ + url: `/pages/chat-detail/chat-detail?id=${currentCharacter.id}&name=${encodeURIComponent(currentCharacter.name)}` + }) + }, 1000) + } else { + // 正式模式:调用微信支付 + const payParams = res.payParams || res.pay_params || res.data?.payParams || res.data?.pay_params + + if (!payParams || !payParams.timeStamp) { + wx.showToast({ title: '支付参数错误', icon: 'none' }) + return + } + + wx.requestPayment({ + timeStamp: payParams.timeStamp, + nonceStr: payParams.nonceStr, + package: payParams.package, + signType: payParams.signType || 'RSA', + paySign: payParams.paySign, + success: async () => { + wx.showToast({ title: '购买成功', icon: 'success' }) + + // 刷新爱心余额 + this.loadHeartBalance() + + // 记录已解锁 + const newUnlocked = { ...this.data.unlockedProfiles } + newUnlocked[currentCharacter.id] = true + this.setData({ + unlockedProfiles: newUnlocked, + showHeartPopup: false, + currentCharacter: null + }) + + // 延迟后跳转到聊天页面 + setTimeout(() => { + wx.navigateTo({ + url: `/pages/chat-detail/chat-detail?id=${currentCharacter.id}&name=${encodeURIComponent(currentCharacter.name)}` + }) + }, 1000) + }, + fail: (err) => { + if (err.errMsg !== 'requestPayment:fail cancel') { + wx.showToast({ title: '支付失败', icon: 'none' }) + } + } + }) + } + } else { + wx.showToast({ title: res.message || '创建订单失败', icon: 'none' }) + } + } catch (err) { + wx.hideLoading() + console.error('解锁角色失败', err) + wx.showToast({ title: '网络错误,请重试', icon: 'none' }) + } finally { + this.setData({ purchasing: false }) + } + }, + + /** + * 跳转到用户协议 + */ + goToUserAgreement() { + wx.navigateTo({ url: '/pages/agreement/agreement?code=user-agreement' }) + }, + + /** + * 跳转到隐私政策 + */ + goToPrivacyPolicy() { + wx.navigateTo({ url: '/pages/agreement/agreement?code=privacy-policy' }) + }, + + onUpgrade() { + const { currentIndex, profiles, unlockedProfiles, heartCount, unlockHeartsCost } = this.data + + if (currentIndex >= profiles.length) return + + // 检查爱心值,不足时提示并跳转充值页面 + if (heartCount < unlockHeartsCost) { + wx.showToast({ title: '爱心值不足,去充值', icon: 'none' }) + setTimeout(() => { + this.setData({ showVipModal: false }) + wx.navigateTo({ url: '/pages/recharge/recharge' }) + }, 1500) + return + } + + const currentId = profiles[currentIndex].id + + const newUnlocked = { ...unlockedProfiles } + newUnlocked[currentId] = true + + // 扣减爱心值 + this.setData({ + unlockedProfiles: newUnlocked, + showVipModal: false, + heartCount: heartCount - unlockHeartsCost + }) + + wx.showToast({ + title: '兑换成功!', + icon: 'success' + }) + }, + + onPurchase() { + // 跳转到充值页面 + wx.navigateTo({ + url: '/pages/recharge/recharge' + }) + }, + + // ==================== 其他操作 ==================== + + onStoryTap(e) { + const index = e.currentTarget.dataset.index + const storyUser = this.data.storyUsers[index] + if (storyUser && storyUser.id) { + // 跳转到角色详情页 + wx.navigateTo({ + url: `/pages/character-detail/character-detail?id=${storyUser.id}` + }) + } else { + wx.showToast({ + title: storyUser.name + '的故事', + icon: 'none' + }) + } + }, + + onNotification() { + wx.showToast({ + title: '暂无新消息', + icon: 'none' + }) + }, + + // Tab bar navigation - 需要登录的页面检查登录状态 + switchTab(e) { + const path = e.currentTarget.dataset.path + const app = getApp() + + // 消息页面需要登录 + if (path === '/pages/chat/chat') { + if (!app.globalData.isLoggedIn) { + wx.navigateTo({ + url: '/pages/login/login?redirect=' + encodeURIComponent(path) + }) + return + } + } + wx.switchTab({ url: path }) + }, + + onTest() { + wx.showToast({ + title: '测试功能', + icon: 'none' + }) + }, + + /** + * 检查注册奖励领取资格 + */ + async checkRegistrationReward() { + if (!app.globalData.isLoggedIn) return + + try { + const res = await api.lovePoints.checkRegistrationReward() + console.log('[index] 注册奖励检查结果:', res) + if (res.success && res.data && res.data.eligible) { + this.setData({ + showRegistrationReward: true, + registrationRewardAmount: res.data.amount || 0 + }) + } + } catch (err) { + console.error('[index] 检查注册奖励失败:', err) + } + }, + + /** + * 领取注册奖励 + */ + async onClaimReward() { + if (this.data.claiming) return + + this.setData({ claiming: true }) + wx.showLoading({ title: '领取中...' }) + + try { + const res = await api.lovePoints.claimRegistrationReward() + wx.hideLoading() + + if (res.success) { + wx.showToast({ + title: '领取成功', + icon: 'success', + duration: 2000 + }) + + this.setData({ + showRegistrationReward: false + }) + + // 如果后端返回了免费畅聊时间,可以做提示 + if (res.data && res.data.free_chat_time) { + console.log('[index] 获得免费畅聊时间:', res.data.free_chat_time); + setTimeout(() => { + wx.showModal({ + title: '领取成功', + content: '恭喜获得 100 爱心 + 60 分钟免费畅聊时间!', + confirmText: '去聊天', + success: (modalRes) => { + if (modalRes.confirm) { + // 如果有当前正在查看的角色,直接跳过去 + const { currentIndex, profiles } = this.data + if (profiles && profiles[currentIndex]) { + const char = profiles[currentIndex] + wx.navigateTo({ + url: `/pages/chat-detail/chat-detail?id=${char.id}&name=${encodeURIComponent(char.name)}` + }) + } else { + wx.switchTab({ url: '/pages/chat/chat' }) + } + } + } + }); + }, 2000); + } + + // 刷新余额 + this.loadHeartBalance() + } else { + wx.showToast({ + title: res.message || '领取失败', + icon: 'none' + }) + } + } catch (err) { + wx.hideLoading() + console.error('[index] 领取注册奖励失败:', err) + wx.showToast({ + title: '网络错误,请重试', + icon: 'none' + }) + } finally { + this.setData({ claiming: false }) + } + }, + + /** + * 关闭注册奖励弹窗 + */ + closeRewardPopup() { + this.setData({ + showRegistrationReward: false + }) + }, + + /** + * 检查 GF100 弹窗状态 + */ + async checkGf100Popup() { + if (!app.globalData.isLoggedIn) return + + try { + const res = await api.lovePoints.checkGf100Status() + console.log('[index] GF100 检查结果:', res) + if (res.success && res.data && res.data.showPopup) { + const imageUrl = res.data.imageUrl; + this.setData({ + showGf100Popup: true, + gf100ImageUrl: imageUrl ? util.getFullImageUrl(imageUrl) : '/images/gf100.png' + }) + } + } catch (err) { + console.error('[index] 检查 GF100 弹窗失败:', err) + } + }, + + /** + * 领取 GF100 奖励 + */ + async onClaimGf100() { + if (this.data.claiming) return + + this.setData({ claiming: true }) + wx.showLoading({ title: '领取中...', mask: true }) + + try { + const res = await api.lovePoints.claimGf100() + wx.hideLoading() + + if (res.success) { + wx.showToast({ + title: '领取成功', + icon: 'success', + duration: 2000 + }) + + this.setData({ + showGf100Popup: false + }) + + // 弹出获得 60 分钟免费畅聊的提示 + setTimeout(() => { + wx.showModal({ + title: '领取成功', + content: '恭喜获得 100 爱心 + 60 分钟免费畅聊时间!', + confirmText: '去聊天', + success: (modalRes) => { + if (modalRes.confirm) { + wx.switchTab({ url: '/pages/chat/chat' }) + } + } + }); + }, 500); + + // 刷新余额 + this.loadHeartBalance() + } else { + wx.showToast({ + title: res.message || '领取失败', + icon: 'none' + }) + } + } catch (err) { + wx.hideLoading() + console.error('[index] 领取 GF100 失败:', err) + wx.showToast({ + title: '网络错误,请重试', + icon: 'none' + }) + } finally { + this.setData({ claiming: false }) + } + }, + + /** + * 关闭 GF100 弹窗 + */ + closeGf100Popup() { + this.setData({ + showGf100Popup: false + }) + }, + + /** + * 用户点击右上角分享 + */ + onShareAppMessage() { + const { shareConfig } = this.data + const referralCode = wx.getStorageSync('referralCode') || '' + + api.promotion.recordShare({ + type: 'app_message', + page: '/pages/index/index', + referralCode: referralCode + }).catch(err => console.error('记录分享失败:', err)) + + this.recordShareReward() + + if (shareConfig) { + return { + title: shareConfig.title, + path: `${shareConfig.path}?referralCode=${referralCode}`, + imageUrl: shareConfig.imageUrl + } + } + + return { + title: '欢迎来到心伴俱乐部', + desc: '随时可聊 一直陪伴', + path: `/pages/index/index?referralCode=${referralCode}`, + imageUrl: '/images/icon-heart-new.png' + } + }, + + /** + * 用户分享到朋友圈 + */ + onShareTimeline() { + const { shareConfig } = this.data + const referralCode = wx.getStorageSync('referralCode') || '' + + api.promotion.recordShare({ + type: 'timeline', + page: '/pages/index/index', + referralCode: referralCode + }).catch(err => console.error('记录分享失败:', err)) + + this.recordShareReward() + + if (shareConfig) { + return { + title: shareConfig.title, + query: `referralCode=${referralCode}`, + imageUrl: shareConfig.imageUrl + } + } + + return { + title: '心伴俱乐部 - 随时可聊 一直陪伴', + query: `referralCode=${referralCode}`, + imageUrl: '/images/icon-heart-new.png' + } + }, + + /** + * 静默记录分享奖励(分享人A获得+100爱心值) + */ + async recordShareReward() { + try { + const res = await api.lovePoints.share() + console.log('[index] 分享爱心值奖励:', res) + } catch (err) { + console.error('[index] 记录分享奖励失败:', err) + } + } +}) diff --git a/pages/index/index.json b/pages/index/index.json new file mode 100644 index 0000000..b16ef3e --- /dev/null +++ b/pages/index/index.json @@ -0,0 +1,4 @@ +{ + "navigationStyle": "custom", + "usingComponents": {} +} diff --git a/pages/index/index.wxml b/pages/index/index.wxml new file mode 100644 index 0000000..6c4dac4 --- /dev/null +++ b/pages/index/index.wxml @@ -0,0 +1,276 @@ + + + + + + 陪伴 + + + + + + + + + + + + + + + + + {{item.name}} + + + + + + + + 左右滑动 + + + + + + + + + + + + + + + + + + + + {{profiles[currentIndex].name}} + {{profiles[currentIndex].height}} + + + {{profiles[currentIndex].location}} + | + {{profiles[currentIndex].occupation}} + + {{profiles[currentIndex].bio}} + + {{item}} + + + + + + + + + + + + + {{profiles[currentIndex].name}} + + + + + + + + + 正在加载... + 请稍候 + + + + + + + + {{error || '暂时没有更多伙伴了'}} + 点击下方按钮重新加载 + + + + + + + + + 喜欢 + + + + 声音 + + + + {{unlockedProfiles[profiles[currentIndex].id] ? '已选' : '选择'}} + + + + + + + + + + + + + + + 解锁与 {{profiles[currentIndex].name}} 的专属聊天 + + + + + + + + + {{unlockHeartsCost}} 爱心 + {{heartCount >= unlockHeartsCost ? '爱心值充足 立即兑换' : '爱心值不足 去充值'}} + + 兑换 + + + + + ¥ + + + 9.9元 + 限时特惠 立即购买 + + 购买 + + + + 暂不需要 + + + + + + + + + × + + + + + + + + + + + + + + + + + + 解锁与 + {{currentCharacter.name}} + 的专属聊天 + + + + + + + + + + + + + + + + {{unlockHeartsCost}}爱心 + {{heartCount >= unlockHeartsCost ? '余额充足 立即兑换' : '爱心值不足 去充值'}} + + + + 兑换 + + + + + + 暂不需要 + + + + + + + + + + + × + + + + + + + + + 陪伴 + + + + 文娱 + + + + 服务 + + + + + + {{totalUnread}} + 99+ + + + 消息 + + + + 我的 + + + diff --git a/pages/index/index.wxss b/pages/index/index.wxss new file mode 100644 index 0000000..908d341 --- /dev/null +++ b/pages/index/index.wxss @@ -0,0 +1,1061 @@ +/* 首页样式 */ +.page-container { + min-height: 100vh; + background: linear-gradient(180deg, #E8C3D4 0%, #F5E6ED 100%); + position: relative; +} + +/* 顶部导航栏已移除,改用全局 unified-header */ +.nav-title-text { + font-size: 40rpx; + font-weight: bold; + color: #101828; + margin-left:40rpx +} + +/* 内容区域 */ +.content-area { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow-y: auto; /* 允许滚动 */ +} + +/* 顶部 Banner */ +.banner-section { + padding: 20rpx 32rpx 0; +} + +/* 顶部 Banner */ +.banner-section { + padding: 20rpx 32rpx 0; +} + +.home-banner { + width: 100%; + border-radius: 24rpx; + box-shadow: 0 8rpx 24rpx rgba(0,0,0,0.1); + display: block; +} + +.story-scroll { + padding: 24rpx 0; + white-space: nowrap; +} + +.story-list { + display: inline-flex; + gap: 40rpx; + padding: 0 20rpx; +} + +.story-item { + display: flex; + flex-direction: column; + align-items: center; + width: 140rpx; +} + +.story-avatar-wrap { + position: relative; + width: 142rpx; + height: 142rpx; +} + +.story-ring { + position: absolute; + inset: 0; + border-radius: 50%; + background: linear-gradient(135deg, #DEE2E7 0%, #DBE0E7 100%); + box-shadow: 0 31rpx 31rpx rgba(142, 155, 174, 0.2); +} + +.story-avatar { + position: absolute; + top: 10rpx; + left: 10rpx; + width: 122rpx; + height: 122rpx; + border-radius: 50%; + border: 4rpx solid #fff; +} + +.story-name { + margin-top: 8rpx; + font-size: 26rpx; + font-weight: 600; + color: #1a1a1a; + text-align: center; +} + +/* 滑动提示 */ +.swipe-hint { + display: flex; + align-items: center; + justify-content: center; + gap: 16rpx; + padding: 16rpx 0; + opacity: 0.6; +} + +.hint-arrow { + width: 40rpx; + height: 40rpx; + opacity: 0.8; +} + +.hint-text { + font-size: 32rpx; + font-weight: 900; + color: #914584; + letter-spacing: 4rpx; +} + +/* 卡片堆叠 */ +.card-stack { + position: relative; + width: 100%; + height: 1040rpx; + display: flex; + justify-content: center; + margin-top: 16rpx; +} + +.card-next { + position: absolute; + top: 80rpx; + width: 706rpx; + height: 724rpx; + border-radius: 68rpx; + overflow: hidden; + transform: scale(0.95); + opacity: 0.8; + background: #f4f7fb; + box-shadow: 0 40rpx 80rpx rgba(59, 64, 86, 0.15); +} + +.card-bg-image { + width: 100%; + height: 100%; +} + +.card-current { + position: absolute; + top: 0; + width: 706rpx; + height: 800rpx; + transition: transform 0.1s ease-out; + z-index: 10; +} + +.card-current.swipe-left { + animation: swipeLeft 0.3s ease-out forwards; +} + +.card-current.swipe-right { + animation: swipeRight 0.3s ease-out forwards; +} + +@keyframes swipeLeft { + to { + transform: translateX(-1000rpx) rotate(-20deg); + opacity: 0; + } +} + +@keyframes swipeRight { + to { + transform: translateX(1000rpx) rotate(20deg); + opacity: 0; + } +} + +.card-inner { + position: relative; + width: 100%; + height: 724rpx; + margin-top: 52rpx; + border-radius: 68rpx; + overflow: hidden; + background: #f4f7fb; + box-shadow: 0 40rpx 80rpx rgba(59, 64, 86, 0.72); +} + +.card-image { + width: 100%; + height: 100%; +} + +/* 卡片信息覆盖层 */ +.card-overlay { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 96rpx 48rpx 48rpx; + background: linear-gradient(to top, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0.4) 50%, transparent 100%); +} + +.card-info { + color: #fff; +} + +.card-name-row { + display: flex; + align-items: baseline; + gap: 16rpx; + margin-bottom: 12rpx; +} + +.card-name { + font-size: 52rpx; + font-weight: 700; + text-shadow: 0 4rpx 8rpx rgba(0,0,0,0.3); +} + +.card-height { + font-size: 30rpx; + font-weight: 500; + opacity: 0.95; +} + +.card-location-row { + display: flex; + align-items: center; + gap: 16rpx; + font-size: 26rpx; + font-weight: 500; + opacity: 0.9; + margin-bottom: 20rpx; +} + +.card-divider { + opacity: 0.6; +} + +.card-bio { + font-size: 28rpx; + font-weight: 500; + opacity: 0.95; + line-height: 1.6; + margin-bottom: 20rpx; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.card-hobbies { + font-size: 26rpx; + font-weight: 500; + opacity: 0.8; + margin-bottom: 24rpx; +} + +.card-tags { + display: flex; + flex-wrap: wrap; + gap: 16rpx; +} + +.card-tag { + font-size: 22rpx; + font-weight: 700; + background: rgba(255,255,255,0.25); + backdrop-filter: blur(20rpx); + padding: 8rpx 20rpx; + border-radius: 14rpx; + border: 2rpx solid rgba(255,255,255,0.2); +} + +/* 悬浮操作按钮 - 放在卡片和底部导航栏之间 */ +.floating-actions { + position: fixed; + left: 0; + right: 0; + bottom: 230rpx; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: 64rpx; + z-index: 50; + padding: 0 48rpx; +} + +.action-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 16rpx; + flex: 0 0 auto; +} + +.action-btn .action-icon { + width: 116rpx; + height: 116rpx; + background: rgba(0,0,0,0.3); + backdrop-filter: blur(20rpx); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + border: 3rpx solid rgba(255,255,255,0.3); + padding: 28rpx; + box-sizing: border-box; + box-shadow: 0 8rpx 24rpx rgba(0,0,0,0.4); +} + +/* 爱心按钮 - 白色背景 */ +.heart-btn .action-icon { + background: #fff; + border: 3rpx solid rgba(255,255,255,0.3); + box-shadow: 0 8rpx 24rpx rgba(251, 44, 54, 0.3); +} + +/* 声音按钮 - 淡紫色半透明背景 */ +.voice-btn .action-icon { + background: rgba(145, 69, 132, 0.6); + border: 3rpx solid rgba(255,255,255,0.3); + box-shadow: 0 8rpx 24rpx rgba(145, 69, 132, 0.4); +} + +.action-btn.liked .action-icon { + background: #fff; +} + +.action-label { + font-size: 34rpx; + font-weight: 900; + color: #fff; + text-shadow: 0 2rpx 8rpx rgba(0,0,0,0.8), 0 4rpx 12rpx rgba(0,0,0,0.6); + letter-spacing: 2rpx; +} + +.select-btn .action-icon { + background: linear-gradient(135deg, #4ade80 0%, #16a34a 100%); +} + +.select-btn.unlocked .action-icon { + background: linear-gradient(135deg, #22c55e 0%, #15803d 100%); + box-shadow: 0 0 0 4rpx #fff; +} + +/* 顶部头像 */ +.card-host { + position: absolute; + top: 14rpx; + left: 6rpx; + display: flex; + align-items: center; + gap: 20rpx; + z-index: 20; +} + +.host-avatar-wrap { + width: 142rpx; + height: 142rpx; + border-radius: 50%; + overflow: hidden; + border: 4rpx solid #fff; + box-shadow: 0 8rpx 24rpx rgba(0,0,0,0.2); +} + +.host-avatar { + width: 100%; + height: 100%; +} + +.host-name { + font-size: 34rpx; + font-weight: 700; + color: #fff; + text-shadow: 0 4rpx 12rpx rgba(0,0,0,0.4); + padding: 20rpx; +} + +/* 空状态 */ +.card-empty { + position: absolute; + top: 80rpx; + width: 706rpx; + height: 724rpx; + border-radius: 68rpx; + background: #f4f7fb; + box-shadow: 0 40rpx 80rpx rgba(59, 64, 86, 0.15); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48rpx; +} + +.empty-icon-wrap { + width: 192rpx; + height: 192rpx; + background: #fff; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 16rpx 48rpx rgba(0,0,0,0.1); + margin-bottom: 32rpx; +} + +.empty-icon-wrap.loading { + background: linear-gradient(135deg, #E9D5FF 0%, #FDF2F8 100%); +} + +.empty-icon { + width: 80rpx; + height: 80rpx; +} + +.empty-icon.rotating { + animation: rotate 1.5s linear infinite; +} + +@keyframes rotate { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.empty-title { + font-size: 40rpx; + font-weight: 700; + color: #1f2937; + margin-bottom: 16rpx; +} + +.empty-desc { + font-size: 28rpx; + color: #6b7280; + margin-bottom: 48rpx; +} + +.refresh-btn { + background: #914584; + color: #fff; + font-size: 36rpx; + font-weight: 700; + padding: 24rpx 64rpx; + border-radius: 100rpx; + border: none; + box-shadow: 0 16rpx 32rpx rgba(145, 69, 132, 0.3); +} + +/* 弹窗 */ +.modal-mask { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.5); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} + +.modal-content { + width: 640rpx; + background: #f8f9fc; + border-radius: 48rpx; + overflow: hidden; +} + +.modal-header { + background: #fff; + padding: 64rpx 48rpx 40rpx; + text-align: center; + border-radius: 0 0 60rpx 60rpx; + box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.05); + margin-bottom: 32rpx; +} + +.modal-avatar-wrap { + position: relative; + width: 192rpx; + height: 192rpx; + margin: 0 auto 40rpx; +} + +.modal-avatar { + width: 100%; + height: 100%; + border-radius: 50%; + border: 6rpx solid #fff; + box-shadow: 0 16rpx 32rpx rgba(0,0,0,0.15); +} + +.modal-lock { + position: absolute; + bottom: -8rpx; + right: -8rpx; + width: 48rpx; + height: 48rpx; + background: #fff; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.15); +} + +.lock-icon { + width: 32rpx; + height: 32rpx; +} + +.modal-title { + font-size: 42rpx; + font-weight: 800; + color: #111827; + line-height: 1.4; +} + +.modal-title .highlight { + color: #914584; +} + +.modal-options { + padding: 0 40rpx 24rpx; +} + +.option-item { + background: #fff; + border-radius: 32rpx; + padding: 32rpx; + display: flex; + align-items: center; + gap: 28rpx; + margin-bottom: 24rpx; + box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.05); + border: 2rpx solid #f3f4f6; +} + +.option-item.highlight { + background: linear-gradient(135deg, #914584 0%, #7a3a6f 100%); + border: none; + box-shadow: 0 16rpx 32rpx rgba(145, 69, 132, 0.3); +} + +.option-icon { + width: 88rpx; + height: 88rpx; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.option-icon.grass { + background: #dcfce7; +} + +.option-icon.grass image { + width: 44rpx; + height: 44rpx; +} + +.option-icon.money { + background: rgba(255,255,255,0.2); +} + +.money-symbol { + font-size: 48rpx; + font-weight: 800; + color: #fff; +} + +.option-info { + flex: 1; +} + +.option-price { + font-size: 40rpx; + font-weight: 800; + color: #111827; + display: block; +} + +.option-desc { + font-size: 30rpx; + font-weight: 600; + color: #6b7280; + display: block; + margin-top: 4rpx; +} + +.option-info.light .option-price, +.option-info.light .option-desc { + color: #fff; +} + +.option-info.light .option-desc { + opacity: 0.95; +} + +.option-btn { + background: #f3f4f6; + color: #4b5563; + font-size: 36rpx; + font-weight: 800; + padding: 16rpx 40rpx; + border-radius: 100rpx; +} + +.option-btn.light { + background: #fff; + color: #914584; + box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.1); +} + +.modal-cancel { + display: block; + text-align: center; + font-size: 32rpx; + font-weight: 600; + color: #9ca3af; + padding: 20rpx 0 48rpx; +} + + +/* 自定义底部导航栏 - 完全匹配Figma设计 */ +.custom-tabbar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 194rpx; + background: #fff; + display: flex; + align-items: flex-start; + justify-content: space-around; + padding-top: 24rpx; + z-index: 999; + border-top: 2rpx solid #F3F4F6; +} + +.tabbar-item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12rpx; + width: 150rpx; + height: 120rpx; +} + +.tabbar-icon { + width: 68rpx; + height: 68rpx; +} + +.tabbar-text { + font-family: Arial, sans-serif; + font-size: 40rpx; + font-weight: 700; + color: #A58AA5; + line-height: 1; +} + +.tabbar-text.active { + color: #B06AB3; +} + +.message-icon-wrapper { + position: relative; + width: 68rpx; + height: 68rpx; +} + +.message-icon-wrapper .tabbar-icon { + width: 68rpx; + height: 68rpx; +} + +.message-dot { + position: absolute; + top: -8rpx; + right: -8rpx; + width: 24rpx; + height: 24rpx; + background: #FB2C36; + border: 2rpx solid #fff; + border-radius: 50%; +} + +/* 消息数字角标 */ +.message-badge { + position: absolute; + top: -10rpx; + right: -16rpx; + min-width: 36rpx; + height: 36rpx; + padding: 0 8rpx; + background: #FB2C36; + border: 3rpx solid #fff; + border-radius: 18rpx; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; +} + +.message-badge text { + font-size: 22rpx; + font-weight: 600; + color: #fff; + line-height: 1; +} + + + +/* ==================== 解锁弹窗样式 - 完全匹配Figma设计 ==================== */ + +/* 弹窗遮罩 */ +.unlock-popup-mask { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 1001; + display: flex; + align-items: center; + justify-content: center; +} + +/* 弹窗主体 */ +.unlock-popup { + width: 680rpx; + background: #F8F9FC; + border-radius: 48rpx; + overflow: hidden; + position: relative; + box-shadow: 0 50rpx 100rpx -24rpx rgba(0, 0, 0, 0.25); + animation: unlockPopupIn 0.3s ease-out; + min-height: 720rpx; +} + +@keyframes unlockPopupIn { + from { + opacity: 0; + transform: scale(0.9); + } + to { + opacity: 1; + transform: scale(1); + } +} + +/* 关闭按钮 */ +.unlock-popup-close { + position: absolute; + top: 32rpx; + right: 32rpx; + width: 32rpx; + height: 32rpx; + display: flex; + justify-content: center; + align-items: center; + opacity: 0.7; + z-index: 10; +} + +.unlock-popup-close text { + font-size: 40rpx; + color: #0A0A0A; + line-height: 1; + font-weight: 300; +} + +/* 顶部白色区域 */ +.unlock-popup-header { + background: #fff; + padding: 56rpx 48rpx 48rpx; + border-radius: 0 0 60rpx 60rpx; + box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + align-items: center; +} + +/* 头像容器 */ +.unlock-avatar-container { + position: relative; + width: 192rpx; + height: 192rpx; + margin-bottom: 40rpx; +} + +.unlock-avatar-wrap { + width: 192rpx; + height: 192rpx; + border-radius: 50%; + overflow: hidden; + border: 4rpx solid #fff; + box-shadow: 0 20rpx 30rpx -6rpx rgba(0, 0, 0, 0.1), 0 8rpx 12rpx -4rpx rgba(0, 0, 0, 0.1); +} + +.unlock-avatar { + width: 100%; + height: 100%; +} + +/* 锁图标 */ +.unlock-lock-icon { + position: absolute; + bottom: -8rpx; + right: -8rpx; + width: 56rpx; + height: 56rpx; + background: #fff; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4rpx 12rpx -2rpx rgba(0, 0, 0, 0.1), 0 8rpx 12rpx -2rpx rgba(0, 0, 0, 0.1); +} + +.unlock-lock-icon image { + width: 32rpx; + height: 32rpx; +} + +/* 标题 */ +.unlock-title { + text-align: center; + font-size: 42rpx; + font-weight: 900; + color: #101828; + line-height: 1.25; + letter-spacing: -0.5rpx; +} + +.unlock-title .highlight { + color: #914584; +} + +/* 选项区域 */ +.unlock-options { + padding: 56rpx 40rpx 64rpx; + display: flex; + flex-direction: column; + gap: 32rpx; +} + +/* 选项卡通用样式 */ +.unlock-option-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 32rpx; + height: 150rpx; + border-radius: 32rpx; + box-shadow: 0 2rpx 4rpx -2rpx rgba(0, 0, 0, 0.1), 0 2rpx 6rpx rgba(0, 0, 0, 0.1); +} + +.option-left { + display: flex; + align-items: center; + gap: 28rpx; + flex: 1; +} + +/* 图标容器 */ +.option-icon { + width: 96rpx; + height: 96rpx; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + box-shadow: 0 2rpx 4rpx -2rpx rgba(0, 0, 0, 0.1), 0 2rpx 6rpx 0 rgba(0, 0, 0, 0.1); +} + +/* 分享图标 */ +.share-icon { + background: #FFF0F5; +} + +.share-icon image { + width: 52rpx; + height: 52rpx; +} + +/* 爱心图标 */ +.hearts-icon { + background: #FFF0F5; +} + +.hearts-icon image { + width: 52rpx; + height: 52rpx; +} + +/* 选项信息 */ +.option-info { + display: flex; + flex-direction: column; + gap: 8rpx; + flex: 1; + min-width: 0; +} + +.option-title { + font-size: 36rpx; + font-weight: 900; + line-height: 1.4; + letter-spacing: -0.025em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.option-desc { + font-size: 28rpx; + font-weight: 700; + line-height: 1.5; + opacity: 0.8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* 按钮 */ +.option-btn { + width: 174rpx; + height: 100rpx; + border-radius: 100rpx; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + box-shadow: 0 4rpx 8rpx -4rpx rgba(0, 0, 0, 0.1), 0 8rpx 12rpx -2rpx rgba(0, 0, 0, 0.1); +} + +.option-btn text { + font-size: 40rpx; + font-weight: 900; + letter-spacing: -0.025em; + line-height: 1.5; +} + +/* ==================== GF100 弹窗样式 ==================== */ +.gf100-mask { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.75); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; + backdrop-filter: blur(5px); +} + +.gf100-content { + position: relative; + width: 62.5%; + max-width: 480rpx; + animation: gf100In 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +@keyframes gf100In { + from { + transform: scale(0.5); + opacity: 0; + } + to { + transform: scale(1); + opacity: 1; + } +} + +.gf100-image { + width: 100%; + display: block; + border-radius: 20rpx; + box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.5); +} + +.gf100-close { + position: absolute; + top: -80rpx; + right: 0; + width: 60rpx; + height: 60rpx; + border: 2rpx solid #fff; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.close-icon { + color: #fff; + font-size: 40rpx; + line-height: 1; +} + + +/* 分享选项 - 粉色背景 */ +.share-option { + background: #FFF0F5; + border: 2rpx solid #FCE7F3; +} + +.share-option .option-title { + color: #914584; +} + +.share-option .option-desc { + color: #914584; +} + +.share-btn { + background: #914584; +} + +.share-btn text { + color: #FFFFFF; +} + +/* 爱心选项 - 白色背景 */ +.hearts-option { + background: #FFFFFF; + border: 2rpx solid #F3F4F6; +} + +.hearts-option .option-title { + color: #101828; +} + +.hearts-option .option-desc { + color: #6A7282; +} + +.hearts-btn { + background: #F3F4F6; +} + +.hearts-btn text { + color: #4A5565; +} + +/* 暂不需要 */ +.unlock-cancel { + text-align: center; + padding: 24rpx 0 0; +} + +.unlock-cancel text { + font-size: 32rpx; + font-weight: 700; + color: #99A1AF; + letter-spacing: 0.5rpx; + line-height: 1.5; +} diff --git a/pages/interest-partner/interest-partner.js b/pages/interest-partner/interest-partner.js new file mode 100644 index 0000000..477b5b0 --- /dev/null +++ b/pages/interest-partner/interest-partner.js @@ -0,0 +1,281 @@ +// pages/interest-partner/interest-partner.js - 兴趣搭子页面(Figma设计) +const api = require('../../utils/api') +const config = require('../../config/index') +const app = getApp() + +Page({ + data: { + statusBarHeight: 44, + navBarHeight: 44, + totalNavHeight: 88, + + // 二维码弹窗 + showQrcodeModal: false, + selectedPartner: null, // 当前选中的搭子对象 + + // 兴趣搭子列表 + partnerList: [], + loading: false, + + // 固定的6个分类(与Figma设计一致) + fixedCategories: [ + { name: '美食聚餐', icon: 'food-icon', desc: '同城美食 共享美味' }, + { name: '旅游出行', icon: 'travel-icon', desc: '结伴出游 共度美好' }, + { name: '唱歌观影', icon: 'entertainment-icon', desc: '老歌金曲 经典影视' }, + { name: '舞蹈走秀', icon: 'dance-icon', desc: '展现风采 舞动人生' }, + { name: '书画摄影', icon: 'art-icon', desc: '陶冶情操 记录生活之美' }, + { name: '运动康养', icon: 'sports-icon', desc: '多种运动 身心健康' } + ] + }, + + onLoad() { + // 计算导航栏高度 + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + const menuButton = wx.getMenuButtonBoundingClientRect() + const navBarHeight = menuButton.height + (menuButton.top - statusBarHeight) * 2 + const totalNavHeight = statusBarHeight + navBarHeight + + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight + }) + + // 加载兴趣搭子列表 + this.loadPartnerList() + }, + + /** + * 页面显示时刷新数据 + */ + onShow() { + // 每次显示页面时重新加载数据,确保数据是最新的 + this.loadPartnerList() + }, + + /** + * 下拉刷新 + */ + onPullDownRefresh() { + console.log('下拉刷新兴趣搭子列表') + this.loadPartnerList().then(() => { + wx.stopPullDownRefresh() + wx.showToast({ + title: '刷新成功', + icon: 'success' + }) + }).catch(() => { + wx.stopPullDownRefresh() + }) + }, + + /** + * 加载兴趣搭子列表 + */ + async loadPartnerList() { + if (this.data.loading) return + + this.setData({ loading: true }) + + try { + // 添加时间戳参数,防止缓存 + const timestamp = Date.now() + const res = await api.interest.getList({ _t: timestamp }) + + console.log('[兴趣搭子] API原始响应:', JSON.stringify(res).substring(0, 500)) + + // 线上API返回格式:{ success: true, data: [...] } 或 { success: true, data: { list: [...] } } + if (res.success && res.data) { + // 兼容两种返回格式 + let partnerList = Array.isArray(res.data) ? res.data : (res.data.list || []) + + console.log('[兴趣搭子] 解析后的列表数量:', partnerList.length) + if (partnerList.length > 0) { + console.log('[兴趣搭子] 第一条数据示例:', JSON.stringify(partnerList[0])) + } + + // 处理图片URL - 根据API文档,icon和qr_code已经是完整的API路径 + partnerList = partnerList.map(item => { + // 处理icon字段 + let iconUrl = '/images/icon-interest-default.png' // 默认图标 + if (item.icon && item.icon.trim()) { + if (item.icon.startsWith('http://') || item.icon.startsWith('https://')) { + // 已经是完整URL + iconUrl = item.icon + } else if (item.icon.startsWith('/')) { + // 相对路径,需要拼接域名 + iconUrl = `https://ai-c.maimanji.com${item.icon}` + } else { + // 不以/开头,添加/再拼接 + iconUrl = `https://ai-c.maimanji.com/${item.icon}` + } + } + + // 处理qr_code字段 + let qrCodeUrl = '' + if (item.qr_code && item.qr_code.trim()) { + if (item.qr_code.startsWith('http://') || item.qr_code.startsWith('https://')) { + qrCodeUrl = item.qr_code + } else if (item.qr_code.startsWith('/')) { + qrCodeUrl = `https://ai-c.maimanji.com${item.qr_code}` + } else { + qrCodeUrl = `https://ai-c.maimanji.com/${item.qr_code}` + } + } + + console.log(`[兴趣搭子] ${item.name} - 原始icon: ${item.icon}, 处理后: ${iconUrl}`) + + return { + ...item, + icon: iconUrl, + qr_code: qrCodeUrl + } + }) + + console.log(`[${new Date().toLocaleTimeString()}] 兴趣搭子列表加载成功,共 ${partnerList.length} 条数据`) + this.setData({ + partnerList, + loading: false + }) + + // 返回Promise以支持下拉刷新 + return Promise.resolve() + } else { + console.warn('兴趣搭子列表返回格式异常:', res) + this.setData({ loading: false }) + wx.showToast({ + title: '加载失败', + icon: 'none' + }) + return Promise.reject() + } + } catch (err) { + console.error('加载兴趣搭子列表失败:', err) + this.setData({ loading: false }) + wx.showToast({ + title: '网络错误', + icon: 'none' + }) + return Promise.reject(err) + } + }, + + /** + * 返回上一页 + */ + onBack() { + wx.navigateBack() + }, + + /** + * 点击兴趣卡片 + */ + onInterestTap(e) { + const { partner } = e.currentTarget.dataset + + if (!partner) { + wx.showToast({ + title: '数据加载中', + icon: 'none' + }) + return + } + + // 检查是否有二维码 + if (!partner.qr_code || !partner.qr_code.trim()) { + wx.showToast({ + title: '二维码暂未配置', + icon: 'none' + }) + return + } + + // 二维码URL已在loadPartnerList中处理过,直接使用 + this.setData({ + showQrcodeModal: true, + selectedPartner: partner + }) + }, + + /** + * 关闭二维码弹窗 + */ + onCloseQrcodeModal() { + this.setData({ + showQrcodeModal: false, + selectedPartner: null + }) + }, + + /** + * 保存二维码 + */ + onSaveQrcode() { + const { selectedPartner } = this.data + + if (!selectedPartner || !selectedPartner.qr_code) { + wx.showToast({ + title: '二维码加载中', + icon: 'none' + }) + return + } + + wx.showLoading({ title: '保存中...' }) + + // 下载图片 + wx.downloadFile({ + url: selectedPartner.qr_code, + success: (res) => { + if (res.statusCode === 200) { + // 保存到相册 + wx.saveImageToPhotosAlbum({ + filePath: res.tempFilePath, + success: () => { + wx.hideLoading() + wx.showToast({ + title: '已保存到相册', + icon: 'success' + }) + this.onCloseQrcodeModal() + }, + fail: (err) => { + wx.hideLoading() + if (err.errMsg.includes('auth deny')) { + wx.showModal({ + title: '需要授权', + content: '请允许访问相册以保存二维码', + confirmText: '去设置', + success: (modalRes) => { + if (modalRes.confirm) { + wx.openSetting() + } + } + }) + } else { + wx.showToast({ + title: '保存失败', + icon: 'none' + }) + } + } + }) + } else { + wx.hideLoading() + wx.showToast({ + title: '下载失败', + icon: 'none' + }) + } + }, + fail: () => { + wx.hideLoading() + wx.showToast({ + title: '下载失败', + icon: 'none' + }) + } + }) + } +}) diff --git a/pages/interest-partner/interest-partner.json b/pages/interest-partner/interest-partner.json new file mode 100644 index 0000000..28e169a --- /dev/null +++ b/pages/interest-partner/interest-partner.json @@ -0,0 +1,6 @@ +{ + "navigationStyle": "custom", + "navigationBarTextStyle": "black", + "enablePullDownRefresh": true, + "backgroundColor": "#F2EDFF" +} diff --git a/pages/interest-partner/interest-partner.wxml b/pages/interest-partner/interest-partner.wxml new file mode 100644 index 0000000..17db869 --- /dev/null +++ b/pages/interest-partner/interest-partner.wxml @@ -0,0 +1,106 @@ + + + + + + + 返回 + + 兴趣搭子 + + + + + + + + + + 寻找志同道合的伙伴 + 加入感兴趣的社群,开启精彩退休生活 + + + + + + + + + + + + + {{item.name}} + + + {{item.member_count}} + + + {{item.description}} + + + + + 加载中... + + + + 暂无兴趣搭子数据 + + + + + + + + + + + + + + + + 如何加入? + 点击上方感兴趣的分类,保存二维码图片或直接扫码,即可加入我们的官方企业微信社群。 + + + + + + + + + + + + + + + + + + + + + {{selectedPartner.group_name || '加入兴趣群'}} + + + {{selectedPartner.group_description || '找到志同道合的伙伴'}} + + + + + + + + + 保存二维码 + + + + diff --git a/pages/interest-partner/interest-partner.wxss b/pages/interest-partner/interest-partner.wxss new file mode 100644 index 0000000..59f734a --- /dev/null +++ b/pages/interest-partner/interest-partner.wxss @@ -0,0 +1,489 @@ +/* 兴趣搭子页面样式 - Figma设计 */ +page { + background: #F8F8F8; +} + +.page-container { + min-height: 100vh; + background: #F8F8F8; +} + +/* 顶部导航栏已移除,改用全局 unified-header */ + +/* 内容滚动区域 */ +.content-scroll { + height: 100vh; + box-sizing: border-box; +} + +/* 顶部Banner */ +.hero-banner { + margin: 32rpx 32rpx 48rpx; + height: 240rpx; + border-radius: 32rpx; + position: relative; + overflow: hidden; + background: linear-gradient(90deg, rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.2) 50%, rgba(0, 0, 0, 0) 100%), + url('https://images.unsplash.com/photo-1529156069898-49953e39b3ac?w=800') center/cover; + box-shadow: 0px 8rpx 12rpx -8rpx rgba(0, 0, 0, 0.1), + 0px 20rpx 30rpx -6rpx rgba(0, 0, 0, 0.1), + 0px 0px 0px 2rpx rgba(0, 0, 0, 0.05); +} + +.hero-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(90deg, rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.2) 50%, rgba(0, 0, 0, 0) 100%); +} + +.hero-content { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + justify-content: center; + gap: 16rpx; + padding: 0 48rpx; + height: 100%; +} + +.hero-title { + font-size: 52rpx; + font-weight: 900; + color: #FFFFFF; + line-height: 1.5; + letter-spacing: 0.025em; + text-shadow: 0px 6rpx 12rpx rgba(0, 0, 0, 0.12); +} + +.hero-subtitle { + font-size: 34rpx; + font-weight: 700; + color: rgba(255, 255, 255, 0.95); + line-height: 1.5; + letter-spacing: 0.025em; + text-shadow: 0px 2rpx 8rpx rgba(0, 0, 0, 0.15); +} + +/* 兴趣分类列表 */ +.interest-list { + padding: 0 32rpx; + display: flex; + flex-direction: column; + gap: 32rpx; +} + +.interest-card { + display: flex; + align-items: center; + gap: 40rpx; + padding: 2rpx 42rpx; + height: 238rpx; + background: #FFFFFF; + border: 2.324rpx solid #F1F5F9; + border-radius: 40rpx; + box-shadow: 0px 2rpx 6rpx 0px rgba(0, 0, 0, 0.1), + 0px 2rpx 4rpx -2rpx rgba(0, 0, 0, 0.1); + transition: all 0.3s ease; +} + +.interest-card:active { + transform: scale(0.98); + box-shadow: 0px 1rpx 2rpx -1rpx rgba(0, 0, 0, 0.1); +} + +/* 图标样式 */ +.interest-icon { + width: 152rpx; + height: 152rpx; + border-radius: 52rpx; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + position: relative; + box-sizing: border-box; +} + +/* 图标图片 */ +.icon-image { + width: 152rpx; + height: 152rpx; +} + +/* 加载提示 */ +.loading-tip { + text-align: center; + padding: 80rpx 0; + font-size: 28rpx; + color: #999; +} + +/* 空状态提示 */ +.empty-tip { + text-align: center; + padding: 120rpx 0; + font-size: 28rpx; + color: #999; +} + +/* 保留原有的固定分类图标样式作为备用 */ + +/* 美食聚餐 - 橙色 */ +.food-icon { + background: #FFF7ED; +} + +.food-icon::before { + content: ''; + position: absolute; + width: 80rpx; + height: 80rpx; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23FF7A45' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 8h1a4 4 0 0 1 0 8h-1'/%3E%3Cpath d='M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z'/%3E%3Cline x1='6' y1='1' x2='6' y2='4'/%3E%3Cline x1='10' y1='1' x2='10' y2='4'/%3E%3Cline x1='14' y1='1' x2='14' y2='4'/%3E%3C/svg%3E"); + background-size: contain; + background-repeat: no-repeat; + background-position: center; +} + +/* 旅游出行 - 青色 */ +.travel-icon { + background: #ECFEFF; +} + +.travel-icon::before { + content: ''; + position: absolute; + width: 80rpx; + height: 80rpx; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2336CFC9' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z'/%3E%3Cpolyline points='7.5 4.21 12 6.81 16.5 4.21'/%3E%3Cpolyline points='7.5 19.79 7.5 14.6 3 12'/%3E%3Cpolyline points='21 12 16.5 14.6 16.5 19.79'/%3E%3Cpolyline points='3.27 6.96 12 12.01 20.73 6.96'/%3E%3Cline x1='12' y1='22.08' x2='12' y2='12'/%3E%3C/svg%3E"); + background-size: contain; + background-repeat: no-repeat; + background-position: center; +} + +/* 唱歌观影 - 紫色 */ +.entertainment-icon { + background: #FAF5FF; +} + +.entertainment-icon::before { + content: ''; + position: absolute; + width: 80rpx; + height: 80rpx; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239254DE' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 18V5l12-2v13'/%3E%3Ccircle cx='6' cy='18' r='3'/%3E%3Ccircle cx='18' cy='16' r='3'/%3E%3C/svg%3E"); + background-size: contain; + background-repeat: no-repeat; + background-position: center; +} + +/* 舞蹈走秀 - 粉色 */ +.dance-icon { + background: #FDF2F8; +} + +.dance-icon::before { + content: ''; + position: absolute; + width: 80rpx; + height: 80rpx; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23F759AB' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='5' r='2'/%3E%3Cpath d='M10 22v-5l-1-1v-4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4l-1 1v5'/%3E%3C/svg%3E"); + background-size: contain; + background-repeat: no-repeat; + background-position: center; +} + +/* 书画摄影 - 蓝色 */ +.art-icon { + background: #EEF2FF; +} + +.art-icon::before { + content: ''; + position: absolute; + width: 80rpx; + height: 80rpx; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23597EF7' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z'/%3E%3Ccircle cx='12' cy='13' r='4'/%3E%3C/svg%3E"); + background-size: contain; + background-repeat: no-repeat; + background-position: center; +} + +/* 运动康养 - 绿色 */ +.sports-icon { + background: #F0FDF4; +} + +.sports-icon::before { + content: ''; + position: absolute; + width: 80rpx; + height: 80rpx; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2373D13D' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M22 12h-4l-3 9L9 3l-3 9H2'/%3E%3C/svg%3E"); + background-size: contain; + background-repeat: no-repeat; + background-position: center; +} + +/* 信息区域 */ +.interest-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 16rpx; +} + +.interest-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 24rpx; +} + +.interest-name { + font-size: 48rpx; + font-weight: 700; + color: #1D293D; + line-height: 1.5; + letter-spacing: -0.025em; +} + +.interest-members { + display: flex; + align-items: center; + gap: 12rpx; + flex-shrink: 0; +} + +.members-icon { + width: 40rpx; + height: 40rpx; +} + +.members-count { + font-size: 32rpx; + font-weight: 700; + color: #62748E; + line-height: 1.5; +} + +.interest-desc { + font-size: 44rpx; + font-weight: 700; + color: #45556C; + line-height: 1.5; +} + +/* 如何加入说明 */ +.how-to-join { + display: flex; + align-items: flex-start; + gap: 24rpx; + padding: 32rpx 32rpx 32rpx 32rpx; + margin: 0 32rpx 0; + background: #FFF7ED; + border-radius: 32rpx; +} + +.join-icon-wrapper { + flex-shrink: 0; + width: 72rpx; + height: 72rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.join-icon { + position: relative; + width: 40rpx; + height: 40rpx; +} + +.dot { + position: absolute; + width: 6.66rpx; + height: 6.66rpx; + background: transparent; + border: 3.33rpx solid #F54900; + border-radius: 50%; +} + +.dot-1 { + top: 5rpx; + left: 5rpx; +} + +.dot-2 { + top: 5rpx; + right: 5rpx; +} + +.dot-3 { + bottom: 5rpx; + right: 5rpx; +} + +.dot-4 { + bottom: 5rpx; + left: 5rpx; +} + +.line { + position: absolute; + top: 50%; + left: 11.66rpx; + width: 16.66rpx; + height: 0; + border-top: 3.33rpx solid #F54900; + transform: translateY(-50%); +} + +.join-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 8rpx; + padding-right: 32rpx; +} + +.join-title { + font-size: 32rpx; + font-weight: 700; + color: #1D293D; + line-height: 1.5; +} + +.join-desc { + font-size: 28rpx; + font-weight: 400; + color: #45556C; + line-height: 1.625; +} + +/* 二维码弹窗 */ +.qrcode-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; +} + +/* 遮罩层 */ +.modal-mask { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4rpx); +} + +/* 弹窗内容 */ +.modal-content { + position: relative; + width: 680rpx; + background: #FFFFFF; + border-radius: 64rpx; + padding: 64rpx; + box-shadow: 0 50rpx 100rpx -24rpx rgba(0, 0, 0, 0.25); + display: flex; + flex-direction: column; + align-items: center; + z-index: 1; +} + +/* 关闭按钮 */ +.close-btn { + position: absolute; + top: 32rpx; + right: 32rpx; + width: 72rpx; + height: 72rpx; + background: #F1F5F9; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s ease; +} + +.close-btn:active { + transform: scale(0.9); + background: #E2E8F0; +} + +.close-icon { + width: 40rpx; + height: 40rpx; +} + +/* 标题 */ +.modal-title { + font-size: 48rpx; + font-weight: 700; + color: #1D293D; + text-align: center; + margin-bottom: 16rpx; + line-height: 1.5; +} + +/* 副标题 */ +.modal-subtitle { + font-size: 32rpx; + color: #62748E; + text-align: center; + margin-bottom: 48rpx; + line-height: 1.5; +} + +/* 二维码容器 */ +.qrcode-container { + width: 440rpx; + height: 440rpx; + background: #F8FAFC; + border: 2rpx solid #F1F5F9; + border-radius: 40rpx; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 48rpx; + overflow: hidden; +} + +.qrcode-image { + width: 404rpx; + height: 404rpx; + border-radius: 24rpx; +} + +/* 保存按钮 */ +.save-btn { + width: 552rpx; + height: 116rpx; + background: #07C160; + border-radius: 100rpx; + display: flex; + align-items: center; + justify-content: center; + font-size: 40rpx; + font-weight: 700; + color: #FFFFFF; + box-shadow: 0 20rpx 30rpx -6rpx rgba(220, 252, 231, 1), + 0 8rpx 12rpx -8rpx rgba(220, 252, 231, 1); + transition: all 0.3s ease; +} + +.save-btn:active { + transform: scale(0.96); + box-shadow: 0 10rpx 20rpx -6rpx rgba(220, 252, 231, 1); +} diff --git a/pages/invite/invite.js b/pages/invite/invite.js new file mode 100644 index 0000000..633c5d2 --- /dev/null +++ b/pages/invite/invite.js @@ -0,0 +1,662 @@ +const api = require('../../utils/api') +const auth = require('../../utils/auth') +const config = require('../../config/index') + +Page({ + data: { + invitationCode: '', + referralCode: '', // 推荐码(佣金系统) + shareUrl: '', + stats: { + total_invites: 0, + completed_invites: 0 + }, + // 佣金统计 + commissionStats: { + totalReferrals: 0, + commissionBalance: 0, + totalEarned: 0 + }, + lovePoints: 0, + shareCount: 0, + maxShareCount: 3, + loading: false, + isDistributor: false, // 是否是分销商 + // 海报相关 + posterTemplates: [], + currentPosterIndex: 0, + generatingPoster: false, + shareConfig: null + }, + + async onLoad() { + // 统一登录验证 + const isValid = await auth.ensureLogin({ + pageName: 'invite', + redirectUrl: '/pages/invite/invite' + }) + + if (!isValid) return + + // 验证通过后,稍作延迟确保token稳定 + await new Promise(resolve => setTimeout(resolve, 50)) + + // 加载数据 + this.loadData() + }, + + onShow() { + // 每次显示页面时刷新数据(已登录的情况下) + const app = getApp() + if (app.globalData.isLoggedIn) { + this.loadData() + } + }, + + /** + * 加载所有数据 + */ + async loadData() { + if (this.data.loading) return + + this.setData({ loading: true }) + + try { + await Promise.all([ + this.loadInvitationCode(), + this.loadCommissionStats(), // 加载佣金统计 + this.getLovePoints(), + this.getTodayShareCount(), + this.loadPosterTemplates(), + this.loadShareConfig() + ]) + } catch (error) { + console.error('加载数据失败:', error) + } finally { + this.setData({ loading: false }) + } + }, + + /** + * 获取邀请码 + */ + async loadInvitationCode() { + try { + const res = await api.lovePoints.getInvitationCode() + if (res.success) { + this.setData({ + invitationCode: res.data.invitation_code, + shareUrl: res.data.share_url, + stats: res.data.stats + }) + } + } catch (error) { + console.error('获取邀请码失败:', error) + // 401错误由API层统一处理,这里只处理其他错误 + if (error.code !== 401) { + wx.showToast({ + title: error.message || '获取邀请码失败', + icon: 'none' + }) + } + } + }, + + /** + * 加载佣金统计数据 + */ + async loadCommissionStats() { + try { + const res = await api.commission.getStats() + if (res.success && res.data) { + this.setData({ + referralCode: res.data.referralCode || '', + isDistributor: res.data.isDistributor || false, + commissionStats: { + totalReferrals: res.data.totalReferrals || 0, + commissionBalance: res.data.commissionBalance || 0, + totalEarned: res.data.totalEarned || 0 + } + }) + } + } catch (error) { + console.error('获取佣金统计失败:', error) + // 不显示错误提示,静默失败 + } + }, + + /** + * 获取爱心值余额(使用后端新接口) + */ + getLovePoints() { + return new Promise((resolve, reject) => { + const token = wx.getStorageSync('auth_token') + if (!token) { + resolve() + return + } + + wx.request({ + url: `${config.API_BASE_URL}/love-points/balance`, + method: 'GET', + header: { + 'Authorization': `Bearer ${token}` + }, + success: (res) => { + console.log('爱心值余额API响应:', res.data) + if (res.data.success) { + this.setData({ + lovePoints: res.data.data.love_points + }) + resolve(res.data.data) + } else { + reject(new Error(res.data.error)) + } + }, + fail: reject + }) + }) + }, + + /** + * 获取今日分享次数(使用后端新接口) + */ + getTodayShareCount() { + return new Promise((resolve, reject) => { + const token = wx.getStorageSync('auth_token') + if (!token) { + resolve() + return + } + + wx.request({ + url: `${config.API_BASE_URL}/love-points/share-count`, + method: 'GET', + header: { + 'Authorization': `Bearer ${token}` + }, + success: (res) => { + console.log('分享次数API响应:', res.data) + if (res.data.success) { + this.setData({ + shareCount: res.data.data.count, + maxShareCount: res.data.data.max + }) + resolve(res.data.data) + } else { + reject(new Error(res.data.error)) + } + }, + fail: reject + }) + }) + }, + + /** + * 复制邀请码 + */ + copyInviteCode() { + const { isDistributor, referralCode, invitationCode } = this.data; + // 如果是分销商且有推荐码,优先复制推荐码 + const codeToCopy = (isDistributor && referralCode) ? referralCode : invitationCode; + + if (!codeToCopy) { + wx.showToast({ + title: '邀请码加载中...', + icon: 'none' + }) + return + } + + wx.setClipboardData({ + data: codeToCopy, + success: () => { + wx.showToast({ + title: '邀请码已复制', + icon: 'success' + }) + } + }) + }, + + /** + * 查看佣金中心 + */ + goToCommission() { + wx.navigateTo({ + url: '/pages/commission/commission' + }) + }, + + /** + * 点击分享按钮 + */ + onShareTap() { + console.log('用户点击了分享按钮') + + // 检查登录状态 + const token = wx.getStorageSync('auth_token') + if (!token) { + wx.showModal({ + title: '提示', + content: '请先登录', + confirmText: '去登录', + success: (res) => { + if (res.confirm) { + wx.navigateTo({ + url: '/pages/login/login' + }) + } + } + }) + return + } + + // 检查是否达到上限 + if (this.data.shareCount >= this.data.maxShareCount) { + wx.showToast({ + title: '今日分享次数已达上限', + icon: 'none', + duration: 2000 + }) + return + } + + // 立即记录分享 + this.recordShare() + }, + + /** + * 记录分享并获得爱心值(使用后端新接口) + */ + recordShare() { + const token = wx.getStorageSync('auth_token') + if (!token) return + + wx.showLoading({ + title: '处理中...', + mask: true + }) + + wx.request({ + url: `${config.API_BASE_URL}/love-points/share`, + method: 'POST', + header: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + success: (res) => { + wx.hideLoading() + console.log('分享奖励API响应:', res.data) + + if (res.data.success) { + const newShareCount = this.data.shareCount + 1 + const newLovePoints = this.data.lovePoints + res.data.data.earned + + this.setData({ + shareCount: newShareCount, + lovePoints: newLovePoints + }) + + wx.showToast({ + title: `+${res.data.data.earned} 爱心值`, + icon: 'success', + duration: 2000 + }) + + const app = getApp() + if (app.globalData) { + app.globalData.lovePoints = newLovePoints + } + + this.triggerPageRefresh() + } else { + wx.showToast({ + title: res.data.error || '分享失败', + icon: 'none', + duration: 2000 + }) + } + }, + fail: (error) => { + wx.hideLoading() + console.error('分享奖励API调用失败:', error) + wx.showToast({ + title: '网络错误,请稍后重试', + icon: 'none', + duration: 2000 + }) + } + }) + }, + + /** + * 静默记录分享奖励(用于 onShareAppMessage/onShareTimeline) + * 不显示 loading 和 Toast,后台静默调用 + * 分享人A获得+100爱心值 + */ + async recordShareReward() { + try { + const token = wx.getStorageSync('auth_token') + if (!token) return + + const res = await new Promise((resolve, reject) => { + wx.request({ + url: `${config.API_BASE_URL}/love-points/share`, + method: 'POST', + header: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + success: resolve, + fail: reject + }) + }) + + if (res.data && res.data.success && res.data.data) { + const newShareCount = this.data.shareCount + 1 + const newLovePoints = this.data.lovePoints + (res.data.data.earned || 0) + + this.setData({ + shareCount: newShareCount, + lovePoints: newLovePoints + }) + + console.log('[invite] 分享爱心值奖励:', res.data.data.earned) + } + } catch (err) { + console.error('[invite] 记录分享奖励失败:', err) + } + }, + + /** + * 触发其他页面刷新 + */ + triggerPageRefresh() { + const pages = getCurrentPages() + if (pages.length > 1) { + const prevPage = pages[pages.length - 2] + // 如果上一个页面有刷新方法,调用它 + if (prevPage.onLovePointsUpdate) { + prevPage.onLovePointsUpdate() + } + if (prevPage.loadData) { + prevPage.loadData() + } + } + }, + + /** + * 微信分享配置 + * 用户点击分享按钮时触发,返回分享信息 + * 同时记录爱心值奖励(分享人A获得+100) + */ + onShareAppMessage() { + const userId = wx.getStorageSync('user_id') || '' + const { referralCode, isDistributor, shareConfig } = this.data + const inviterParam = userId ? `?inviter=${userId}` : '' + const referralParam = referralCode ? `?referralCode=${referralCode}` : '' + + // 记录爱心值奖励(分享人A获得+100) + this.recordShareReward() + + if (shareConfig && (userId || (isDistributor && referralCode))) { + return { + title: shareConfig.title, + desc: shareConfig.desc || '', + path: `${shareConfig.path || '/pages/index/index'}${isDistributor && referralCode ? referralParam : inviterParam}`, + imageUrl: shareConfig.imageUrl + } + } + + if (isDistributor && referralCode) { + return { + title: `我的推荐码:${referralCode},注册即可享受优惠!`, + path: `/pages/index/index?referralCode=${referralCode}`, + imageUrl: shareConfig?.imageUrl || '/images/share-cover.jpg' + } + } + + if (userId) { + return { + title: '邀请你一起来玩AI陪伴,超多有趣角色等你来聊~', + path: `/pages/index/index?inviter=${userId}`, + imageUrl: shareConfig?.imageUrl || '/images/share-cover.jpg' + } + } + + return { + title: 'AI情感陪伴,随时可聊 一直陪伴', + path: '/pages/index/index', + imageUrl: shareConfig?.imageUrl || '/images/share-cover.jpg' + } + }, + + /** + * 分享到朋友圈 + */ + onShareTimeline() { + const { referralCode, isDistributor, shareConfig } = this.data + const userId = wx.getStorageSync('user_id') || '' + + this.recordShareReward() + + if (shareConfig && (userId || (isDistributor && referralCode))) { + return { + title: shareConfig.title, + query: isDistributor && referralCode ? `referralCode=${referralCode}` : (userId ? `inviter=${userId}` : ''), + imageUrl: shareConfig.imageUrl + } + } + + if (isDistributor && referralCode) { + return { + title: `推荐码:${referralCode},注册即可享受优惠!`, + query: `referralCode=${referralCode}`, + imageUrl: shareConfig?.imageUrl || '/images/share-cover.jpg' + } + } + + if (userId) { + return { + title: '邀请你一起来玩AI陪伴~', + query: `inviter=${userId}`, + imageUrl: shareConfig?.imageUrl || '/images/share-cover.jpg' + } + } + + return { + title: 'AI情感陪伴', + query: '', + imageUrl: shareConfig?.imageUrl || '/images/share-cover.jpg' + } + }, + + /** + * 加载海报模板 + * 调用后端API获取海报图片 + */ + async loadPosterTemplates() { + try { + const res = await api.pageAssets.getAssets('poster_templates') + + if (res.success && res.data && res.data.length > 0) { + const posterTemplates = res.data.map(item => ({ + id: item.id, + imageUrl: this.processImageUrl(item.asset_url || item.imageUrl || item.url) + })) + this.setData({ + posterTemplates, + currentPosterIndex: 0 + }) + console.log(`加载了 ${posterTemplates.length} 个海报模板`) + } else { + this.setDefaultPosterTemplates() + } + } catch (error) { + console.error('加载海报模板失败:', error) + this.setDefaultPosterTemplates() + } + }, + + /** + * 设置默认海报模板(降级方案 - 使用CDN URL) + */ + setDefaultPosterTemplates() { + const cdnBase = 'https://ai-c.maimanji.com/images' + this.setData({ + posterTemplates: [ + { id: 1, imageUrl: `${cdnBase}/service-banner-1.png` }, + { id: 2, imageUrl: `${cdnBase}/service-banner-2.png` } + ], + currentPosterIndex: 0 + }) + console.log('使用默认海报模板配置') + }, + + /** + * 加载分享配置 + */ + async loadShareConfig() { + try { + const res = await api.promotion.getShareConfig('invite') + if (res.success && res.data) { + const util = require('../../utils/util') + const shareConfig = { + ...res.data, + imageUrl: res.data.imageUrl ? util.getFullImageUrl(res.data.imageUrl) : '' + } + this.setData({ shareConfig }) + } + } catch (error) { + console.error('加载分享配置失败', error) + } + }, + + /** + * 切换海报 + */ + onPosterChange(e) { + this.setData({ + currentPosterIndex: e.detail.current + }) + }, + + /** + * 生成并保存海报 + */ + async generatePoster() { + if (this.data.generatingPoster) return + this.setData({ generatingPoster: true }) + + wx.showLoading({ title: '生成中...' }) + + try { + const template = this.data.posterTemplates[this.data.currentPosterIndex] + const userInfo = wx.getStorageSync('user_info') || {} + const nickname = userInfo.nickname || '神秘用户' + + const query = wx.createSelectorQuery() + query.select('#posterCanvas') + .fields({ node: true, size: true }) + .exec(async (res) => { + if (!res[0]) { + throw new Error('Canvas not found') + } + + const canvas = res[0].node + const ctx = canvas.getContext('2d') + const dpr = wx.getSystemInfoSync().pixelRatio + + canvas.width = res[0].width * dpr + canvas.height = res[0].height * dpr + ctx.scale(dpr, dpr) + + const width = 300 + const height = 533 + + // 绘制背景 + const bgImage = canvas.createImage() + bgImage.src = template.imageUrl + await new Promise((resolve) => { + bgImage.onload = resolve + bgImage.onerror = (e) => { console.error('BG Error', e); resolve() } + }) + ctx.drawImage(bgImage, 0, 0, width, height) + + // 绘制二维码背景 + ctx.fillStyle = '#FFFFFF' + ctx.fillRect(20, height - 120, 100, 100) + + // 模拟二维码 + ctx.fillStyle = '#000000' + ctx.fillRect(25, height - 115, 90, 90) + + // 绘制文字 + ctx.fillStyle = '#FFFFFF' + ctx.font = 'bold 18px sans-serif' + ctx.fillText(nickname, 130, height - 80) + + ctx.font = '14px sans-serif' + ctx.fillText('邀请你体验AI心伴', 130, height - 55) + + wx.canvasToTempFilePath({ + canvas, + success: (fileRes) => { + wx.saveImageToPhotosAlbum({ + filePath: fileRes.tempFilePath, + success: () => { + wx.hideLoading() + wx.showToast({ title: '已保存到相册', icon: 'success' }) + this.setData({ generatingPoster: false }) + }, + fail: (err) => { + console.error('保存失败', err) + wx.hideLoading() + if (err.errMsg.includes('auth')) { + wx.showModal({ + title: '提示', + content: '需要保存图片到相册的权限,是否去设置?', + success: (mRes) => { + if (mRes.confirm) wx.openSetting() + } + }) + } else { + wx.showToast({ title: '保存失败', icon: 'none' }) + } + this.setData({ generatingPoster: false }) + } + }) + }, + fail: (err) => { + console.error('导出图片失败', err) + wx.hideLoading() + this.setData({ generatingPoster: false }) + } + }) + }) + } catch (error) { + console.error('流程错误:', error) + wx.hideLoading() + this.setData({ generatingPoster: false }) + } + }, + + /** + * 查看爱心值明细 + */ + viewTransactions() { + wx.navigateTo({ + url: '/pages/love-transactions/love-transactions' + }) + }, + + /** + * 下拉刷新 + */ + onPullDownRefresh() { + this.loadData().then(() => { + wx.stopPullDownRefresh() + }) + } +}) diff --git a/pages/invite/invite.json b/pages/invite/invite.json new file mode 100644 index 0000000..025fb9f --- /dev/null +++ b/pages/invite/invite.json @@ -0,0 +1,7 @@ +{ + "navigationBarTitleText": "邀请好友", + "navigationBarBackgroundColor": "#A78BFA", + "navigationBarTextStyle": "white", + "enablePullDownRefresh": true, + "backgroundColor": "#A78BFA" +} diff --git a/pages/invite/invite.wxml b/pages/invite/invite.wxml new file mode 100644 index 0000000..2855aec --- /dev/null +++ b/pages/invite/invite.wxml @@ -0,0 +1,107 @@ + + + 邀请好友 + 好友注册并完善资料,你将获得50爱心值 + + + + + + 当前爱心值 + {{lovePoints}} + + + + 今日已获得 + {{shareCount * 20}} + + + + + + + 我的{{isDistributor ? '专属' : ''}}邀请码 + {{isDistributor && referralCode ? referralCode : invitationCode}} + + + + + + + + {{stats.total_invites}} + 邀请人数 + + + {{stats.completed_invites}} + 完成注册 + + + + + + + 活动说明 + + + + + 每次分享获得20爱心值 + + + + 每天最多分享3次,共60爱心值 + + + + 爱心值可用于兑换会员和礼品 + + + + 每日0点重置分享次数 + + + 💰 + 分销商推荐好友消费可获得佣金奖励 + + + + + + + 推广工具 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pages/invite/invite.wxss b/pages/invite/invite.wxss new file mode 100644 index 0000000..de7690e --- /dev/null +++ b/pages/invite/invite.wxss @@ -0,0 +1,488 @@ +.invite-container { + min-height: 100vh; + background: linear-gradient(180deg, #A78BFA 0%, #8B5CF6 100%); + padding: 40rpx; + padding-bottom: 80rpx; +} + +/* 头部 */ +.invite-header { + text-align: center; + color: white; + margin-bottom: 40rpx; + padding: 40rpx 0; +} + +.title { + display: block; + font-size: 56rpx; + font-weight: bold; + margin-bottom: 20rpx; + text-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.2); +} + +.subtitle { + display: block; + font-size: 28rpx; + opacity: 0.9; +} + +/* 爱心值卡片 */ +.love-card { + background: white; + border-radius: 20rpx; + padding: 40rpx; + margin-bottom: 30rpx; + box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.1); + display: flex; + align-items: center; +} + +.love-info { + flex: 1; + text-align: center; +} + +.love-label { + display: block; + font-size: 24rpx; + color: #999; + margin-bottom: 10rpx; +} + +.love-value { + display: block; + font-size: 48rpx; + font-weight: bold; + color: #8B5CF6; +} + +.divider { + width: 2rpx; + height: 80rpx; + background: #eee; +} + +/* 进度卡片 */ +.progress-card { + background: white; + border-radius: 20rpx; + padding: 40rpx; + margin-bottom: 30rpx; + box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.1); +} + +.progress-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20rpx; +} + +.progress-title { + font-size: 32rpx; + font-weight: bold; + color: #333; +} + +.progress-count { + font-size: 28rpx; + color: #8B5CF6; + font-weight: bold; +} + +.progress-bar { + height: 16rpx; + background: #f0f0f0; + border-radius: 8rpx; + overflow: hidden; + margin-bottom: 20rpx; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, #A78BFA 0%, #8B5CF6 100%); + border-radius: 8rpx; + transition: width 0.3s ease; +} + +.progress-tip { + display: block; + font-size: 24rpx; + color: #999; + text-align: center; +} + +/* 推广工具区域 */ +.promotion-section { + background: white; + margin: 30rpx 20rpx; + padding: 30rpx; + border-radius: 20rpx; + box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05); +} + +.section-title { + font-size: 30rpx; + font-weight: 600; + color: #333; + margin-bottom: 24rpx; +} + +/* 海报生成器 */ +.poster-generator { + margin-bottom: 30rpx; +} + +.poster-swiper { + height: 533rpx; /* Canvas尺寸的一半,或者适配屏幕 */ + margin-bottom: 24rpx; +} + +.poster-item { + display: flex; + justify-content: center; + align-items: center; +} + +.poster-wrap { + width: 90%; + height: 100%; + border-radius: 16rpx; + overflow: hidden; + position: relative; + transition: all 0.3s; + transform: scale(0.95); + box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1); +} + +.poster-wrap.active { + transform: scale(1); + box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15); + border: 4rpx solid #a78bfa; +} + +.poster-img { + width: 100%; + height: 100%; +} + +.poster-overlay { + position: absolute; + top: 10rpx; + right: 10rpx; + background: white; + border-radius: 50%; + width: 40rpx; + height: 40rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.generate-btn { + background: linear-gradient(135deg, #a78bfa 0%, #8b5cf6 100%); + color: white; + font-size: 28rpx; + font-weight: 600; + border-radius: 44rpx; + margin-top: 20rpx; +} + +/* 一键推广按钮 */ +.promote-btn { + background: white; + color: #333; + border: 2rpx solid #a78bfa; + margin-top: 0; + box-shadow: none; +} + +.promote-btn .btn-icon { + width: 36rpx; + height: 36rpx; + margin-right: 10rpx; +} + +/* 按钮基础样式覆盖 */ +.action-btn { + width: 100%; + height: 88rpx; + display: flex; + align-items: center; + justify-content: center; + border-radius: 44rpx; + font-size: 30rpx; + font-weight: 600; + margin-top: 20rpx; +} + +.outline-btn { + background: transparent; + color: #999; + border: 2rpx solid #eee; + box-shadow: none; +} + +/* 分享按钮 */ +.share-btn { + background: linear-gradient(135deg, #A78BFA 0%, #8B5CF6 100%); + color: white; + border-radius: 50rpx; + height: 100rpx; + line-height: 100rpx; + font-size: 32rpx; + font-weight: bold; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 10rpx 30rpx rgba(139, 92, 246, 0.4); + border: none; + margin-bottom: 30rpx; +} + +.share-btn::after { + border: none; +} + +.share-btn.disabled { + background: #ccc; + box-shadow: none; +} + +.btn-icon { + width: 40rpx; + height: 40rpx; + margin-right: 10rpx; +} + +/* 佣金收益卡片 */ +.commission-card { + background: linear-gradient(135deg, #B06AB3 0%, #9B4D9E 100%); + border-radius: 20rpx; + padding: 40rpx; + margin-bottom: 30rpx; + box-shadow: 0 10rpx 30rpx rgba(176, 106, 179, 0.3); +} + +.commission-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30rpx; +} + +.commission-title { + font-size: 32rpx; + font-weight: bold; + color: white; +} + +.commission-badge { + background: rgba(255, 255, 255, 0.2); + color: white; + font-size: 22rpx; + padding: 8rpx 20rpx; + border-radius: 20rpx; + border: 1rpx solid rgba(255, 255, 255, 0.3); +} + +.commission-stats { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30rpx; +} + +.commission-item { + flex: 1; + text-align: center; +} + +.commission-value { + display: block; + font-size: 40rpx; + font-weight: bold; + color: white; + margin-bottom: 10rpx; +} + +.commission-label { + display: block; + font-size: 22rpx; + color: rgba(255, 255, 255, 0.8); +} + +.commission-divider { + width: 2rpx; + height: 60rpx; + background: rgba(255, 255, 255, 0.2); +} + +.commission-btn { + background: white; + color: #9B4D9E; + border-radius: 50rpx; + height: 80rpx; + line-height: 80rpx; + font-size: 28rpx; + font-weight: bold; + border: none; +} + +.commission-btn::after { + border: none; +} + +/* 推荐码区域 */ +.referral-code-section { + background: rgba(255, 255, 255, 0.95); + border-radius: 20rpx; + padding: 40rpx; + margin-bottom: 30rpx; + box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.1); + border: 2rpx solid #B06AB3; +} + +/* 邀请码区域 */ +.invite-code-section { + background: rgba(255, 255, 255, 0.95); + border-radius: 20rpx; + padding: 40rpx; + margin-bottom: 30rpx; + box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.1); +} + +.code-display { + text-align: center; + margin-bottom: 30rpx; +} + +.label { + display: block; + font-size: 28rpx; + color: #666; + margin-bottom: 20rpx; +} + +.code { + display: block; + font-size: 56rpx; + font-weight: bold; + color: #8B5CF6; + letter-spacing: 8rpx; + font-family: 'Courier New', monospace; +} + +.copy-btn { + background: linear-gradient(135deg, #A78BFA 0%, #8B5CF6 100%); + color: white; + border-radius: 50rpx; + height: 80rpx; + line-height: 80rpx; + font-size: 28rpx; + border: none; +} + +.copy-btn::after { + border: none; +} + +/* 统计区域 */ +.stats-section { + background: rgba(255, 255, 255, 0.95); + border-radius: 20rpx; + padding: 40rpx; + margin-bottom: 30rpx; + display: flex; + box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.1); +} + +.stat-item { + flex: 1; + text-align: center; +} + +.stat-item .value { + display: block; + font-size: 48rpx; + font-weight: bold; + color: #8B5CF6; + margin-bottom: 10rpx; +} + +.stat-item .label { + display: block; + font-size: 24rpx; + color: #999; +} + +/* 说明卡片 */ +.tips-card { + background: rgba(255, 255, 255, 0.95); + border-radius: 20rpx; + padding: 40rpx; + margin-bottom: 30rpx; + box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.1); +} + +.tips-title { + font-size: 32rpx; + font-weight: bold; + color: #333; + margin-bottom: 30rpx; +} + +.tips-list { + padding-left: 10rpx; +} + +.tip-item { + display: flex; + margin-bottom: 20rpx; + line-height: 40rpx; +} + +.tip-dot { + color: #8B5CF6; + margin-right: 10rpx; + font-size: 32rpx; +} + +.tip-text { + flex: 1; + font-size: 26rpx; + color: #666; +} + +.tip-item.highlight { + background: rgba(176, 106, 179, 0.1); + padding: 15rpx; + border-radius: 10rpx; + margin-top: 10rpx; +} + +.tip-item.highlight .tip-text { + color: #9B4D9E; + font-weight: 500; +} + +/* 操作区域 */ +.action-section { + margin-top: 30rpx; +} + +.action-btn { + background: rgba(255, 255, 255, 0.95); + color: #8B5CF6; + border-radius: 50rpx; + height: 80rpx; + line-height: 80rpx; + font-size: 28rpx; + font-weight: bold; + border: 2rpx solid #8B5CF6; +} + +.action-btn::after { + border: none; +} diff --git a/pages/login/login.js b/pages/login/login.js new file mode 100644 index 0000000..592d0d4 --- /dev/null +++ b/pages/login/login.js @@ -0,0 +1,236 @@ +/** + * 登录页面 + * 显示LOGO、应用名称和微信手机号快速登录按钮 + * 用户需勾选同意协议后才能登录 + * + * 支持持久化登录: + * - 登录成功后保存Token到本地(7天有效期) + * - 再次进入时自动恢复登录状态 + */ +const app = getApp() +const auth = require('../../utils/auth') +const api = require('../../utils/api') +const config = require('../../config/index') + +Page({ + data: { + loginLoading: false, + statusBarHeight: 20, + agreementChecked: false // 协议默认不勾选 + }, + + onLoad(options) { + // 保存来源页面,登录成功后跳转 + this.redirectUrl = options.redirect || '' + + // 获取状态栏高度 + const systemInfo = wx.getSystemInfoSync() + this.setData({ + statusBarHeight: systemInfo.statusBarHeight || 20 + }) + + // 检查是否已登录,如果已登录则直接跳转 + this.checkExistingLogin() + }, + + /** + * 检查现有登录状态 + * 如果已登录且Token有效,直接跳转 + */ + async checkExistingLogin() { + if (auth.isLoggedIn()) { + // 验证服务端Token + const result = await auth.verifyLogin() + if (result.valid) { + console.log('已登录,直接跳转') + this.navigateAfterLogin() + } + } + }, + + /** + * 切换协议勾选状态 + */ + toggleAgreement() { + this.setData({ + agreementChecked: !this.data.agreementChecked + }) + }, + + /** + * 未勾选协议时点击登录按钮 + */ + onLoginBtnTap() { + if (!this.data.agreementChecked) { + wx.showToast({ + title: '请先同意用户协议和隐私协议', + icon: 'none', + duration: 2000 + }) + } + }, + + /** + * 显示用户服务协议 + */ + showUserAgreement() { + wx.navigateTo({ + url: '/pages/agreement/agreement?code=user_service' + }) + }, + + /** + * 显示隐私协议 + */ + showPrivacyPolicy() { + wx.navigateTo({ + url: '/pages/agreement/agreement?code=privacy_policy' + }) + }, + + /** + * 微信手机号快速登录 + * @param {object} e - 事件对象 + */ + async onGetPhoneNumber(e) { + console.log('手机号授权回调', e.detail) + + // 用户取消授权 + if (e.detail.errMsg !== 'getPhoneNumber:ok') { + wx.showToast({ + title: '您已取消授权', + icon: 'none', + duration: 2000 + }) + return + } + + // 获取手机号授权code + const phoneCode = e.detail.code + if (!phoneCode) { + wx.showModal({ + title: '提示', + content: '获取授权信息失败,请重试', + showCancel: false + }) + return + } + + // 显示加载状态 + this.setData({ loginLoading: true }) + wx.showLoading({ title: '登录中...', mask: true }) + + try { + // 1. 获取登录code (用于换取openid) + const loginRes = await new Promise((resolve, reject) => { + wx.login({ + success: resolve, + fail: reject + }) + }) + + if (!loginRes.code) { + throw new Error('获取登录凭证失败') + } + + // 2. 使用app的wxPhoneLogin方法完成登录 (传递两个code) + await app.wxPhoneLogin(phoneCode, loginRes.code) + + wx.hideLoading() + wx.showToast({ + title: '登录成功', + icon: 'success', + duration: 1500 + }) + + // 登录成功后调用爱心值API(B自己获得+100) + this.claimLovePointsLoginReward() + + // 延迟跳转,让用户看到成功提示 + setTimeout(() => { + this.navigateAfterLogin() + }, 1500) + + } catch (err) { + console.error('手机号登录失败', err) + wx.hideLoading() + this.handleLoginError(err) + } finally { + this.setData({ loginLoading: false }) + } + }, + + /** + * 登录成功后跳转 + */ + navigateAfterLogin() { + if (this.redirectUrl) { + // 跳转到来源页面 + wx.redirectTo({ + url: decodeURIComponent(this.redirectUrl), + fail: () => { + // 如果是tabBar页面,使用switchTab + wx.switchTab({ + url: decodeURIComponent(this.redirectUrl).split('?')[0] + }) + } + }) + } else { + // 默认跳转到首页 + wx.switchTab({ + url: '/pages/index/index' + }) + } + }, + + /** + * 处理登录错误 + * @param {object} err - 错误对象 + */ + handleLoginError(err) { + let message = '登录失败,请稍后重试' + + if (err.code === 'PHONE_DECRYPT_FAILED') { + message = '手机号解密失败,请重试' + } else if (err.code === 'WX_SESSION_EXPIRED') { + message = '微信会话已过期,请重新打开小程序' + } else if (err.message) { + message = err.message + } + + wx.showModal({ + title: '登录失败', + content: message, + showCancel: false + }) + }, + + /** + * 返回上一页 + */ + goBack() { + const pages = getCurrentPages() + if (pages.length > 1) { + wx.navigateBack() + } else { + wx.switchTab({ + url: '/pages/index/index' + }) + } + }, + + /** + * 领取登录爱心值奖励(B自己获得+100) + * 后端会同时检查是否有邀请人,如果有则给邀请人也+100 + */ + async claimLovePointsLoginReward() { + try { + const res = await api.lovePoints.login() + if (res.success && res.data && res.data.earned > 0) { + console.log('[login] 登录爱心值奖励:', res.data.earned) + } + } catch (err) { + console.error('[login] 领取登录爱心值失败:', err) + } + } +}) diff --git a/pages/login/login.json b/pages/login/login.json new file mode 100644 index 0000000..089f946 --- /dev/null +++ b/pages/login/login.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "", + "navigationStyle": "custom", + "navigationBarBackgroundColor": "#E8C3D4" +} diff --git a/pages/login/login.wxml b/pages/login/login.wxml new file mode 100644 index 0000000..ae71821 --- /dev/null +++ b/pages/login/login.wxml @@ -0,0 +1,56 @@ + + diff --git a/pages/login/login.wxss b/pages/login/login.wxss new file mode 100644 index 0000000..4c71b79 --- /dev/null +++ b/pages/login/login.wxss @@ -0,0 +1,194 @@ +/* 登录页面样式 */ +.login-page { + min-height: 100vh; + background: linear-gradient(180deg, #E8C3D4 0%, #f5e6ed 50%, #fff 100%); + position: relative; + overflow: hidden; +} + +/* 背景装饰 */ +.bg-decoration { + position: absolute; + top: -200rpx; + right: -200rpx; + width: 600rpx; + height: 600rpx; + background: radial-gradient(circle, rgba(145, 69, 132, 0.1) 0%, transparent 70%); + border-radius: 50%; +} + +/* 导航栏 */ +.nav-bar { + position: relative; + height: 88rpx; + display: flex; + align-items: center; + padding: 0 32rpx; + margin-top: 88rpx; +} + +.back-btn { + width: 72rpx; + height: 72rpx; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.6); + border-radius: 50%; +} + +.back-icon { + width: 40rpx; + height: 40rpx; +} + +/* 主内容区 */ +.content { + display: flex; + flex-direction: column; + align-items: center; + padding: 120rpx 64rpx 80rpx; +} + +/* Logo区域 */ +.logo-section { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 160rpx; +} + +.logo-wrapper { + margin-bottom: 48rpx; +} + +.logo-circle { + width: 200rpx; + height: 200rpx; + background: linear-gradient(135deg, #914584 0%, #B378FE 100%); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 20rpx 60rpx rgba(145, 69, 132, 0.3); +} + +.logo-icon { + width: 100rpx; + height: 100rpx; + filter: brightness(0) invert(1); +} + +.app-name { + font-size: 64rpx; + font-weight: bold; + color: #914584; + margin-bottom: 16rpx; + letter-spacing: 8rpx; +} + +.app-slogan { + font-size: 28rpx; + color: #717182; +} + +/* 登录按钮区域 */ +.login-section { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; +} + +/* 协议勾选区域 */ +.agreement-section { + display: flex; + align-items: flex-start; + margin-bottom: 40rpx; + width: 100%; +} + +.checkbox-wrap { + padding: 8rpx; + margin-right: 12rpx; +} + +.checkbox { + width: 40rpx; + height: 40rpx; + border: 3rpx solid #d1d5db; + border-radius: 8rpx; + display: flex; + align-items: center; + justify-content: center; + background: #fff; + transition: all 0.2s ease; +} + +.checkbox.checked { + background: linear-gradient(135deg, #914584 0%, #B378FE 100%); + border-color: #914584; +} + +.check-icon { + width: 28rpx; + height: 28rpx; + filter: brightness(0) invert(1); +} + +.agreement-text { + flex: 1; + display: flex; + flex-wrap: wrap; + line-height: 48rpx; +} + +.agreement-label { + font-size: 26rpx; + color: #6b7280; +} + +.agreement-link { + font-size: 26rpx; + color: #914584; + font-weight: 500; +} + +.login-btn { + width: 100%; + height: 100rpx; + background: linear-gradient(135deg, #07C160 0%, #2AAE67 100%); + border-radius: 50rpx; + display: flex; + align-items: center; + justify-content: center; + gap: 16rpx; + border: none; + box-shadow: 0 16rpx 40rpx rgba(7, 193, 96, 0.3); +} + +.login-btn::after { + border: none; +} + +.login-btn.loading { + opacity: 0.7; +} + +.login-btn.disabled { + opacity: 0.5; + box-shadow: none; +} + +.btn-icon { + width: 44rpx; + height: 44rpx; +} + +.btn-text { + font-size: 34rpx; + font-weight: bold; + color: #fff; + letter-spacing: 2rpx; + white-space: nowrap; +} diff --git a/pages/love-transactions/love-transactions.js b/pages/love-transactions/love-transactions.js new file mode 100644 index 0000000..e233ee6 --- /dev/null +++ b/pages/love-transactions/love-transactions.js @@ -0,0 +1,143 @@ +const api = require('../../utils/api') +const auth = require('../../utils/auth') + +Page({ + data: { + statusBarHeight: 44, + navBarHeight: 44, + totalNavHeight: 88, + transactions: [], + balance: 0, + loading: false + }, + + async onLoad() { + // 计算导航栏高度 + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + const menuButton = wx.getMenuButtonBoundingClientRect() + const navBarHeight = menuButton.height + (menuButton.top - statusBarHeight) * 2 + const totalNavHeight = statusBarHeight + navBarHeight + + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight + }) + + // 统一登录验证 + const isValid = await auth.ensureLogin({ + pageName: 'love-transactions', + redirectUrl: '/pages/love-transactions/love-transactions' + }) + + if (!isValid) return + + // 验证通过后,稍作延迟确保token稳定 + await new Promise(resolve => setTimeout(resolve, 50)) + + // 加载数据 + this.loadBalance() + this.loadTransactions() + }, + + onShow() { + // 每次显示页面时刷新数据(已登录的情况下) + const app = getApp() + if (app.globalData.isLoggedIn) { + this.loadBalance() + this.loadTransactions() + } + }, + + async loadBalance() { + try { + const res = await api.loveExchange.getOptions() + if (res.success) { + this.setData({ + balance: res.data.current_love_points || 0 + }) + } + } catch (error) { + console.error('加载余额失败:', error) + // 401错误由API层统一处理 + } + }, + + async loadTransactions() { + if (this.data.loading) return + + this.setData({ loading: true }) + + try { + const res = await api.lovePoints.getTransactions({ limit: 100 }) + if (res.success) { + this.setData({ + transactions: res.data + }) + } + } catch (error) { + console.error('加载流水记录失败:', error) + // 401错误由API层统一处理,这里只处理其他错误 + if (error.code !== 401) { + wx.showToast({ + title: error.message || '加载失败', + icon: 'none' + }) + } + } finally { + this.setData({ loading: false }) + } + }, + + // 返回上一页 + goBack() { + wx.navigateBack() + }, + + // 格式化来源 + formatSource(source) { + const sourceMap = { + 'share': '分享小程序', + 'invite': '邀请好友', + 'profile': '完善资料', + 'exchange_vip': '兑换会员', + 'exchange_gift': '兑换礼品', + 'admin': '管理员操作', + 'system': '系统赠送' + } + return sourceMap[source] || source + }, + + // 格式化时间 + formatTime(dateStr) { + const date = new Date(dateStr) + const now = new Date() + const diff = now - date + + // 今天 + if (diff < 86400000 && date.getDate() === now.getDate()) { + return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) + } + + // 昨天 + const yesterday = new Date(now) + yesterday.setDate(yesterday.getDate() - 1) + if (date.getDate() === yesterday.getDate()) { + return '昨天 ' + date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) + } + + // 其他日期 + return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }) + ' ' + + date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) + }, + + // 下拉刷新 + onPullDownRefresh() { + this.loadBalance() + this.loadTransactions() + setTimeout(() => { + wx.stopPullDownRefresh() + }, 1000) + } +}) diff --git a/pages/love-transactions/love-transactions.json b/pages/love-transactions/love-transactions.json new file mode 100644 index 0000000..2879ab0 --- /dev/null +++ b/pages/love-transactions/love-transactions.json @@ -0,0 +1,5 @@ +{ + "navigationStyle": "custom", + "enablePullDownRefresh": true, + "backgroundColor": "#F8F9FA" +} diff --git a/pages/love-transactions/love-transactions.wxml b/pages/love-transactions/love-transactions.wxml new file mode 100644 index 0000000..b2ebcaf --- /dev/null +++ b/pages/love-transactions/love-transactions.wxml @@ -0,0 +1,69 @@ + + + + + + + + + + 爱心值明细 + + + + + + + + + + + + + + + + + + + + 我的爱心 + + {{balance}} + + + + + + + + + + + + + 明细记录 + + + + + + + {{item.description || formatSource(item.source)}} + {{formatTime(item.created_at)}} + + + + {{item.amount > 0 ? '+' : ''}}{{item.amount}} + + + + + + + + 暂无记录 + + + + diff --git a/pages/love-transactions/love-transactions.wxss b/pages/love-transactions/love-transactions.wxss new file mode 100644 index 0000000..0ea5af6 --- /dev/null +++ b/pages/love-transactions/love-transactions.wxss @@ -0,0 +1,246 @@ +/* 页面容器 */ +.page-container { + min-height: 100vh; + background: #F8F9FA; +} + +/* 固定导航栏容器 */ +.nav-container { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 999; + background: rgba(242, 237, 255, 0.6); + backdrop-filter: blur(10px); +} + +.status-bar { + background: transparent; +} + +.nav-bar { + display: flex; + align-items: center; + justify-content: center; + padding: 0 32rpx; + background: transparent; + position: relative; +} + +.nav-back { + position: absolute; + left: 32rpx; + top: 50%; + transform: translateY(-50%); + width: 80rpx; + height: 80rpx; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +.back-icon { + width: 48rpx; + height: 48rpx; +} + +.nav-title { + font-size: 36rpx; + font-weight: 700; + color: #1F2937; + line-height: 1; +} + +/* 内容滚动区域 */ +.content-scroll { + height: 100vh; + box-sizing: border-box; +} + +/* 爱心值卡片 */ +.love-card { + margin: 32rpx 32rpx 40rpx; + border-radius: 24rpx; + overflow: hidden; + position: relative; + height: 280rpx; +} + +.love-card-bg { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, #A78BFA 0%, #C084FC 50%, #E879F9 100%); + opacity: 1; +} + +.love-card-gradient { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: radial-gradient(circle at 80% 20%, rgba(255, 255, 255, 0.2) 0%, transparent 50%); +} + +.love-card-content { + position: relative; + padding: 40rpx; + display: flex; + justify-content: space-between; + align-items: center; + height: 100%; +} + +/* 左侧信息 */ +.love-info { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; +} + +.love-header { + display: flex; + align-items: center; + margin-bottom: 16rpx; +} + +.love-icon-wrap { + width: 48rpx; + height: 48rpx; + background: rgba(255, 255, 255, 0.3); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-right: 12rpx; +} + +.love-icon { + width: 28rpx; + height: 28rpx; +} + +.love-label { + font-size: 28rpx; + color: rgba(255, 255, 255, 0.95); + font-weight: 500; +} + +.love-value { + font-size: 72rpx; + font-weight: 700; + color: #FFFFFF; + line-height: 1.2; + letter-spacing: 1rpx; +} + +/* 右侧装饰 */ +.love-decoration { + width: 160rpx; + height: 160rpx; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.3; +} + +.decoration-icon { + width: 120rpx; + height: 120rpx; +} + +/* 明细记录区域 */ +.records-section { + margin: 0 32rpx 40rpx; +} + +.records-header { + margin-bottom: 24rpx; +} + +.records-title { + font-size: 32rpx; + font-weight: 600; + color: #1F2937; +} + +/* 记录列表 */ +.records-list { + background: #FFFFFF; + border-radius: 16rpx; + overflow: hidden; +} + +.record-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 32rpx; + border-bottom: 1rpx solid #F3F4F6; +} + +.record-item:last-child { + border-bottom: none; +} + +.record-left { + flex: 1; + display: flex; + flex-direction: column; +} + +.record-title { + font-size: 28rpx; + color: #1F2937; + font-weight: 500; + margin-bottom: 8rpx; +} + +.record-time { + font-size: 24rpx; + color: #9CA3AF; +} + +.record-right { + margin-left: 24rpx; +} + +.record-amount { + font-size: 32rpx; + font-weight: 600; +} + +.record-amount.add { + color: #10B981; +} + +/* .record-amount.add::before { + content: '+'; +} */ + +.record-amount.minus { + color: #EF4444; +} + +/* .record-amount.minus::before { + content: '-'; +} */ + +/* 空状态 */ +.empty-state { + background: #FFFFFF; + border-radius: 16rpx; + padding: 120rpx 0; + text-align: center; +} + +.empty-text { + font-size: 28rpx; + color: #9CA3AF; +} diff --git a/pages/medical-apply/medical-apply.js b/pages/medical-apply/medical-apply.js new file mode 100644 index 0000000..12c6a57 --- /dev/null +++ b/pages/medical-apply/medical-apply.js @@ -0,0 +1,315 @@ +// pages/medical-apply/medical-apply.js +// 陪诊就医申请页面 +const api = require('../../utils/api') + +Page({ + data: { + statusBarHeight: 44, + navBarHeight: 44, + totalNavHeight: 88, + showForm: true, + applyStatus: 'none', // none, pending, approved, rejected + statusTitle: '', + statusDesc: '', + isReapply: false, + agreed: false, + formData: { + avatar: '', + realName: '', + gender: '', + age: '', + idCard: '', + city: '', + hospital: '', + healthCert: '', + idFront: '', + idBack: '', + otherCert: '', + introduction: '', + phone: '', + emergencyContact: '', + emergencyPhone: '' + }, + canSubmit: false + }, + + onLoad(options) { + // 获取系统信息设置导航栏高度 + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + const menuButton = wx.getMenuButtonBoundingClientRect() + const navBarHeight = menuButton.height + (menuButton.top - statusBarHeight) * 2 + const totalNavHeight = statusBarHeight + navBarHeight + + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight, + isReapply: options.isReapply === 'true' + }) + + this.checkApplyStatus() + }, + + // 返回上一页 + goBack() { + wx.navigateBack() + }, + + // 检查申请状态 + async checkApplyStatus() { + const token = wx.getStorageSync('auth_token') + if (!token) { + this.setData({ applyStatus: 'none' }) + return + } + + wx.showLoading({ title: '加载中...' }) + try { + // 调用陪诊就医申请状态API(如果后端已实现) + const res = await api.request('/medical/apply') + + if (res.success && res.data) { + const data = res.data + if (data.status === 'approved') { + this.setData({ + applyStatus: 'approved', + statusTitle: '申请已通过', + statusDesc: '恭喜您成为陪诊师!' + }) + } else if (data.status === 'pending') { + this.setData({ + applyStatus: 'pending', + statusTitle: '审核中', + statusDesc: '您的申请正在审核中,请耐心等待' + }) + } else if (data.status === 'rejected') { + this.setData({ + applyStatus: 'rejected', + statusTitle: '申请未通过', + statusDesc: data.rejectReason || '很抱歉,您的申请未通过审核' + }) + } + } + } catch (err) { + console.log('获取申请状态失败,可能API未实现:', err) + this.setData({ applyStatus: 'none' }) + } finally { + wx.hideLoading() + } + }, + + // 重新申请 + reapply() { + this.setData({ + isReapply: true, + applyStatus: 'none' + }) + }, + + // 选择头像 + chooseAvatar() { + this.doChooseMedia('avatar') + }, + + // 上传证书 + uploadCert(e) { + const type = e.currentTarget.dataset.type + this.doChooseMedia(type) + }, + + // 选择媒体文件 + doChooseMedia(field) { + wx.chooseMedia({ + count: 1, + mediaType: ['image'], + sourceType: ['album', 'camera'], + success: async (res) => { + const tempFilePath = res.tempFiles[0].tempFilePath + wx.showLoading({ title: '上传中...' }) + + try { + // 1. 自动压缩图片 + const compressed = await wx.compressImage({ + src: tempFilePath, + quality: 80 + }).catch(err => { + console.warn('压缩图片失败:', err) + return { tempFilePath } + }) + + // 2. 上传到服务器,使用允许的目录 'image' + const uploadRes = await api.uploadFile(compressed.tempFilePath, 'image') + + if (uploadRes.success && uploadRes.data) { + const fieldMap = { + 'avatar': 'formData.avatar', + 'idFront': 'formData.idFront', + 'idBack': 'formData.idBack', + 'healthCert': 'formData.healthCert', + 'otherCert': 'formData.otherCert' + } + + this.setData({ + [fieldMap[field]]: uploadRes.data.url + }) + this.checkCanSubmit() + + wx.showToast({ title: '上传成功', icon: 'success' }) + } else { + wx.showToast({ + title: uploadRes.message || '上传失败', + icon: 'none' + }) + } + } catch (err) { + console.error('上传过程出错:', err) + wx.showToast({ + title: err.message || '上传出错', + icon: 'none' + }) + } finally { + wx.hideLoading() + } + } + }) + }, + + // 输入变化 + onInputChange(e) { + const field = e.currentTarget.dataset.field + const value = e.detail.value + this.setData({ + [`formData.${field}`]: value + }) + this.checkCanSubmit() + }, + + // 选择性别 + selectGender(e) { + const gender = e.currentTarget.dataset.gender + this.setData({ + 'formData.gender': gender + }) + this.checkCanSubmit() + }, + + // 切换协议同意状态 + toggleAgreement() { + this.setData({ + agreed: !this.data.agreed + }) + this.checkCanSubmit() + }, + + // 查看协议 + viewAgreement() { + wx.navigateTo({ + url: '/pages/agreement/agreement?code=medical_service' + }) + }, + + // 检查是否可以提交 + checkCanSubmit() { + const { formData, agreed } = this.data + + // 验证身份证号格式 + const idCardValid = /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(formData.idCard) + + const canSubmit = + formData.realName && + formData.gender && + formData.age && + formData.idCard && idCardValid && + formData.city && + formData.hospital && + formData.healthCert && + formData.idFront && + formData.idBack && + formData.introduction && + formData.introduction.length >= 50 && + formData.phone && + formData.phone.length === 11 && + agreed + + this.setData({ canSubmit }) + }, + + // 提交申请 + async submitApply() { + if (!this.data.canSubmit) return + + const { formData } = this.data + + // 验证手机号 + if (!/^1[3-9]\d{9}$/.test(formData.phone)) { + wx.showToast({ title: '请输入正确的手机号', icon: 'none' }) + return + } + + // 验证年龄 + const age = parseInt(formData.age) + if (age < 18 || age > 60) { + wx.showToast({ title: '年龄需在18-60岁之间', icon: 'none' }) + return + } + + // 验证身份证号 + if (!/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(formData.idCard)) { + wx.showToast({ title: '请输入正确的身份证号', icon: 'none' }) + return + } + + wx.showLoading({ title: '提交中...' }) + try { + const res = await api.request('/medical/apply', { + method: 'POST', + data: { + avatar: formData.avatar, + realName: formData.realName, + gender: formData.gender, + age: age, + idCard: formData.idCard, + city: formData.city, + hospital: formData.hospital, + healthCert: formData.healthCert, + idFront: formData.idFront, + idBack: formData.idBack, + otherCert: formData.otherCert, + introduction: formData.introduction, + phone: formData.phone, + emergencyContact: formData.emergencyContact, + emergencyPhone: formData.emergencyPhone + } + }) + + if (res.success || res.code === 0) { + wx.showToast({ title: '申请已提交', icon: 'success' }) + this.setData({ + applyStatus: 'pending', + statusTitle: '审核中', + statusDesc: '您的申请正在审核中,请耐心等待', + isReapply: false + }) + } else { + wx.showToast({ title: res.message || '提交失败', icon: 'none' }) + } + } catch (err) { + console.error('提交申请失败:', err) + // 如果API未实现,显示提示 + if (err.code === 404 || err.message?.includes('not found')) { + wx.showModal({ + title: '提示', + content: '陪诊就医服务即将开放,敬请期待!', + showCancel: false, + confirmText: '我知道了', + confirmColor: '#b06ab3' + }) + } else { + wx.showToast({ title: err.message || '提交失败', icon: 'none' }) + } + } finally { + wx.hideLoading() + } + } +}) diff --git a/pages/medical-apply/medical-apply.json b/pages/medical-apply/medical-apply.json new file mode 100644 index 0000000..a3e86ce --- /dev/null +++ b/pages/medical-apply/medical-apply.json @@ -0,0 +1,5 @@ +{ + "usingComponents": {}, + "navigationStyle": "custom", + "navigationBarTextStyle": "black" +} diff --git a/pages/medical-apply/medical-apply.wxml b/pages/medical-apply/medical-apply.wxml new file mode 100644 index 0000000..614aa2d --- /dev/null +++ b/pages/medical-apply/medical-apply.wxml @@ -0,0 +1,267 @@ + + + + + + + + 返回 + + 陪诊就医 + + + + + + + + + + + + + + + + {{statusTitle}} + {{statusDesc}} + + + + + + + + + + 个人照片 + * + + + + + + + 上传照片 + + + + 请上传清晰的个人照片 + + + + + + 基本信息 + + + + + 姓名 + * + + + + + + + + + 性别 + * + + + + + + + + + + + + + + 年龄 + * + + + + + + + + + 身份证号 + * + + + + + + + + + + + 服务区域 + + + + + 所在城市 + * + + + + + + + + + 服务医院 + * + + + + + 可填写多个医院,用逗号分隔 + + + + + + + 资质证书 + + + + + 健康证 + * + + + + + + 上传健康证 + + + + + + + 身份证正面 + * + + + + + + 上传身份证正面 + + + + + + + 身份证反面 + * + + + + + + 上传身份证反面 + + + + + + + 其他资质证书 + + + + + + 上传其他证书(选填) + + + 如护理证、急救证等相关资质 + + + + + + + 个人介绍 + * + + + + + + {{formData.introduction.length || 0}}/500 + + + + + + + 联系方式 + + + + + 手机号 + * + + + + + + + + + 紧急联系人 + + + + + + + + + 紧急联系电话 + + + + + + + + + + + + + + 我已阅读并同意 + 《陪诊就医服务协议》 + + + + + + + + + + + + diff --git a/pages/medical-apply/medical-apply.wxss b/pages/medical-apply/medical-apply.wxss new file mode 100644 index 0000000..a6b18e3 --- /dev/null +++ b/pages/medical-apply/medical-apply.wxss @@ -0,0 +1,374 @@ +/* 陪诊就医申请页面样式 */ + +.page-container { + min-height: 100vh; + background: linear-gradient(180deg, #E8C3D4 0%, #F5E6EC 100%); +} + +/* 导航栏 */ +.nav-header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + background: linear-gradient(135deg, #E8C3D4 0%, #D4A5C9 100%); +} + +.nav-content { + display: flex; + align-items: center; + justify-content: space-between; + height: 44px; + padding: 0 16px; +} + +.nav-back { + display: flex; + align-items: center; + min-width: 60px; +} + +.back-icon { + width: 20px; + height: 20px; +} + +.back-text { + font-size: 14px; + color: #333; + margin-left: 4px; +} + +.nav-title { + font-size: 17px; + font-weight: 600; + color: #333; +} + +.nav-placeholder { + min-width: 60px; +} + +/* 内容滚动区域 */ +.content-scroll { + height: 100vh; + padding-bottom: env(safe-area-inset-bottom); +} + +/* 申请表单样式 */ +.apply-form { + padding: 16px; +} + +/* 状态卡片 */ +.status-card { + background: #fff; + border-radius: 16px; + padding: 40px 24px; + text-align: center; + margin-bottom: 16px; +} + +.status-icon { + width: 80px; + height: 80px; + margin: 0 auto 16px; +} + +.status-icon image { + width: 100%; + height: 100%; +} + +.status-title { + display: block; + font-size: 18px; + font-weight: 600; + color: #333; + margin-bottom: 8px; +} + +.status-desc { + font-size: 14px; + color: #666; + line-height: 1.6; +} + +.btn-secondary { + margin-top: 24px; + width: 160px; + height: 44px; + background: #fff; + border: 1px solid #b06ab3; + border-radius: 22px; + color: #b06ab3; + font-size: 15px; +} + +/* 表单内容 */ +.form-content { + background: #fff; + border-radius: 16px; + padding: 24px 20px; +} + +.form-header { + text-align: center; + margin-bottom: 24px; +} + +.form-title { + display: block; + font-size: 20px; + font-weight: 600; + color: #333; + margin-bottom: 8px; +} + +.form-subtitle { + font-size: 14px; + color: #999; +} + +/* 表单区块 */ +.form-section { + margin-bottom: 24px; +} + +.section-header { + display: flex; + align-items: center; + margin-bottom: 16px; +} + +.section-title { + font-size: 16px; + font-weight: 600; + color: #333; +} + +.required { + color: #ff4d4f; + margin-left: 4px; +} + +/* 头像上传 */ +.avatar-upload-area { + display: flex; + justify-content: center; + margin-bottom: 8px; +} + +.avatar-circle { + width: 100px; + height: 100px; + border-radius: 50%; + background: #f5f5f5; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} + +.avatar-image { + width: 100%; + height: 100%; +} + +.upload-placeholder { + text-align: center; +} + +.camera-icon { + width: 32px; + height: 32px; + margin-bottom: 4px; +} + +.upload-text { + font-size: 12px; + color: #999; +} + +.form-tip { + font-size: 12px; + color: #999; + text-align: center; + margin-top: 8px; +} + +/* 表单项 */ +.form-item { + margin-bottom: 16px; +} + +.item-label-row { + display: flex; + align-items: center; + margin-bottom: 8px; +} + +.item-label { + font-size: 14px; + color: #333; +} + +.input-wrapper { + background: #f8f8f8; + border-radius: 8px; + padding: 0 12px; +} + +.item-input { + width: 100%; + height: 44px; + font-size: 14px; + color: #333; +} + +.placeholder { + color: #ccc; +} + +/* 性别选择 */ +.gender-options { + display: flex; + gap: 12px; +} + +.gender-btn { + flex: 1; + height: 44px; + background: #f8f8f8; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + color: #666; + transition: all 0.3s; +} + +.gender-btn.active { + background: linear-gradient(135deg, #E8C3D4 0%, #D4A5C9 100%); + color: #333; + font-weight: 500; +} + +/* 证书上传 */ +.cert-upload { + width: 100%; + height: 120px; + background: #f8f8f8; + border-radius: 8px; + border: 1px dashed #ddd; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.cert-image { + width: 100%; + height: 100%; +} + +.cert-placeholder { + text-align: center; +} + +.upload-icon { + width: 40px; + height: 40px; + margin-bottom: 8px; +} + +/* 文本域 */ +.textarea-wrapper { + background: #f8f8f8; + border-radius: 8px; + padding: 12px; +} + +.intro-textarea { + width: 100%; + height: 120px; + font-size: 14px; + color: #333; + line-height: 1.6; +} + +.textarea-footer { + display: flex; + justify-content: flex-end; + margin-top: 8px; +} + +.char-count { + font-size: 12px; + color: #999; +} + +/* 协议 */ +.agreement-row { + display: flex; + align-items: center; + margin: 24px 0; +} + +.checkbox { + width: 20px; + height: 20px; + border: 1px solid #ddd; + border-radius: 4px; + margin-right: 8px; + display: flex; + align-items: center; + justify-content: center; +} + +.checkbox.checked { + background: linear-gradient(135deg, #b06ab3 0%, #4568dc 100%); + border-color: transparent; +} + +.check-icon { + width: 14px; + height: 14px; +} + +.agreement-text { + flex: 1; +} + +.normal-text { + font-size: 13px; + color: #666; +} + +.link-text { + font-size: 13px; + color: #b06ab3; +} + +/* 提交按钮 */ +.submit-btn { + width: 100%; + height: 48px; + background: linear-gradient(135deg, #b06ab3 0%, #4568dc 100%); + border-radius: 24px; + color: #fff; + font-size: 16px; + font-weight: 500; + border: none; +} + +.submit-btn.disabled { + opacity: 0.5; +} + +/* 底部占位 */ +.bottom-placeholder { + height: 40px; +} diff --git a/pages/my-activities/my-activities.js b/pages/my-activities/my-activities.js new file mode 100644 index 0000000..ceea4eb --- /dev/null +++ b/pages/my-activities/my-activities.js @@ -0,0 +1,118 @@ +const api = require('../../utils/api'); +const util = require('../../utils/util'); + +Page({ + data: { + activeTab: 'upcoming', // upcoming | ended + activities: [], + loading: false, + hasMore: true, + page: 1, + limit: 20, + statusBarHeight: 20 + }, + + onLoad() { + this.setData({ + statusBarHeight: wx.getSystemInfoSync().statusBarHeight + }); + this.loadActivities(true); + }, + + onPullDownRefresh() { + this.loadActivities(true).then(() => { + wx.stopPullDownRefresh(); + }); + }, + + onReachBottom() { + if (this.data.hasMore && !this.data.loading) { + this.loadActivities(false); + } + }, + + switchTab(e) { + const tab = e.currentTarget.dataset.tab; + if (tab === this.data.activeTab) return; + + this.setData({ + activeTab: tab, + activities: [], + page: 1, + hasMore: true + }, () => { + this.loadActivities(true); + }); + }, + + async loadActivities(isRefresh = false) { + if (this.data.loading) return; + + this.setData({ loading: true }); + if (isRefresh) { + this.setData({ page: 1, hasMore: true }); + } + + try { + // 映射 tab 到 API 的 time_status + const timeStatusMap = { + 'upcoming': 'upcoming', + 'ended': 'finished' + }; + + const res = await api.activity.getMyRegistrations({ + page: this.data.page, + limit: this.data.limit, + time_status: timeStatusMap[this.data.activeTab] + }); + + if (res.success && res.data) { + const newList = res.data.list.map(item => { + // 格式化日期和图片 + return { + ...item, + cover_image: util.getFullImageUrl(item.cover_image || item.coverImage), + date_display: item.start_date || item.activityDate, + // 映射时间状态文字 + time_status_text: this.getTimeStatusText(item.activity_time_status), + time_status_class: item.activity_time_status + }; + }); + + this.setData({ + activities: isRefresh ? newList : [...this.data.activities, ...newList], + page: this.data.page + 1, + hasMore: newList.length === this.data.limit + }); + } + } catch (err) { + console.error('Load my activities failed', err); + wx.showToast({ + title: '加载失败', + icon: 'none' + }); + } finally { + this.setData({ loading: false }); + } + }, + + getTimeStatusText(status) { + const map = { + 'pending': '待开始', + 'started': '进行中', + 'finished': '已结束' + }; + return map[status] || ''; + }, + + goDetail(e) { + const id = e.currentTarget.dataset.id; + wx.navigateTo({ + url: `/pages/activity-detail/activity-detail?id=${id}` + }); + }, + + goBack() { + wx.navigateBack(); + } +}); diff --git a/pages/my-activities/my-activities.json b/pages/my-activities/my-activities.json new file mode 100644 index 0000000..3956bc6 --- /dev/null +++ b/pages/my-activities/my-activities.json @@ -0,0 +1,8 @@ +{ + "usingComponents": { + "app-icon": "/components/icon/icon" + }, + "navigationBarTitleText": "我的活动", + "enablePullDownRefresh": true, + "backgroundColor": "#F9FAFB" +} diff --git a/pages/my-activities/my-activities.wxml b/pages/my-activities/my-activities.wxml new file mode 100644 index 0000000..e163fbf --- /dev/null +++ b/pages/my-activities/my-activities.wxml @@ -0,0 +1,75 @@ + + + + + + + 我的活动 + + + + + + + + + 未开始 + + + + 已结束 + + + + + + + + + + + + {{item.time_status_text}} + + + + {{item.title}} + + + + + {{item.date_display}} + + + + {{item.location}} + + + + + + + {{item.status === 'confirmed' ? '已确认' : (item.status === 'pending' ? '待确认' : '已取消')}} + + + {{item.price_text || (item.is_free ? '免费' : '¥' + item.price)}} + + + + + + 正在加载... + 没有更多活动了 + + + + + + + 暂无活动记录 + + + + + + diff --git a/pages/my-activities/my-activities.wxss b/pages/my-activities/my-activities.wxss new file mode 100644 index 0000000..ec943f7 --- /dev/null +++ b/pages/my-activities/my-activities.wxss @@ -0,0 +1,224 @@ +.page-container { + min-height: 100vh; + background-color: #F9FAFB; + display: flex; + flex-direction: column; +} + +.main-content { + flex: 1; + display: flex; + flex-direction: column; + padding-top: 194rpx; /* Height of unified-header */ +} + +/* Tab 切换 */ +.tab-section { + background: #fff; + padding: 0 32rpx; + position: sticky; + top: 194rpx; + z-index: 10; +} + +.tab-list { + display: flex; + height: 88rpx; + border-bottom: 2rpx solid #F3F4F6; +} + +.tab-item { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: 32rpx; + color: #6B7280; + position: relative; + transition: all 0.2s ease; +} + +.tab-item.active { + color: #914584; + font-weight: bold; +} + +.active-line { + position: absolute; + bottom: 0; + width: 40rpx; + height: 4rpx; + background: #914584; + border-radius: 4rpx; +} + +/* 列表滚动区域 */ +.list-scroll { + flex: 1; + height: 0; /* Use flex:1 and height:0 for scroll-view in flex column */ +} + +.activity-list { + padding: 32rpx; +} + +/* 活动卡片 */ +.activity-card { + background: #fff; + border-radius: 32rpx; + margin-bottom: 32rpx; + overflow: hidden; + box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05); + display: flex; + transition: transform 0.2s ease; +} + +.activity-card:active { + transform: scale(0.98); +} + +.card-image-wrap { + width: 200rpx; + height: 200rpx; + flex-shrink: 0; + position: relative; +} + +.card-image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.time-status-badge { + position: absolute; + top: 0; + left: 0; + font-size: 20rpx; + padding: 4rpx 12rpx; + border-bottom-right-radius: 12rpx; + color: #fff; + z-index: 1; +} + +.time-status-badge.pending { background: #0EA5E9; } +.time-status-badge.started { background: #10B981; } +.time-status-badge.finished { background: #9CA3AF; } + +.card-info { + flex: 1; + padding: 20rpx 24rpx; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.activity-title { + font-size: 32rpx; + font-weight: bold; + color: #111827; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; + margin-bottom: 12rpx; +} + +.meta-info { + display: flex; + flex-direction: column; + gap: 8rpx; +} + +.meta-item { + display: flex; + align-items: center; + gap: 8rpx; + font-size: 24rpx; + color: #6B7280; +} + +.card-footer { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 16rpx; +} + +.status-tags { + display: flex; + gap: 12rpx; +} + +.status-tag { + font-size: 20rpx; + padding: 4rpx 12rpx; + border-radius: 8rpx; +} + +.status-tag.pending { + background: #FEF3C7; + color: #D97706; +} + +.status-tag.confirmed { + background: #ECFDF5; + color: #059669; +} + +.status-tag.cancelled { + background: #FEE2E2; + color: #DC2626; +} + +.price-text { + font-size: 28rpx; + font-weight: bold; + color: #914584; +} + +/* 加载状态 */ +.load-status { + text-align: center; + padding: 32rpx; + font-size: 24rpx; + color: #9CA3AF; +} + +/* 空状态 */ +.empty-state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding-bottom: 200rpx; +} + +.empty-icon { + width: 240rpx; + height: 240rpx; + margin-bottom: 32rpx; + opacity: 0.6; +} + +.empty-text { + font-size: 28rpx; + color: #9CA3AF; + margin-bottom: 48rpx; +} + +.go-btn { + width: 240rpx; + height: 80rpx; + border-radius: 40rpx; + font-size: 28rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.safe-bottom-spacer { + height: env(safe-area-inset-bottom); +} diff --git a/pages/notices/detail/detail.js b/pages/notices/detail/detail.js new file mode 100644 index 0000000..dda1a54 --- /dev/null +++ b/pages/notices/detail/detail.js @@ -0,0 +1,64 @@ +const api = require('../../../utils/api') + +Page({ + data: { + notice: null, + loading: true, + error: null + }, + + onLoad(options) { + const id = options.id + if (id) { + this.loadNoticeDetail(id) + } + }, + + onBack() { + wx.navigateBack() + }, + + async loadNoticeDetail(id) { + this.setData({ loading: true, error: null }) + + try { + const res = await api.common.getNotices() + console.log('[notice-detail] 公告列表响应:', res) + + if (res.success && res.data) { + const noticeList = res.data.filter(item => String(item.id) === String(id)) + + if (noticeList.length > 0) { + const item = noticeList[0] + this.setData({ + notice: { + id: item.id, + content: item.content, + linkType: item.linkType || 'none', + linkValue: item.linkValue || '', + createdAt: this.formatDate(item.createdAt) + } + }) + } else { + this.setData({ error: '公告不存在' }) + } + } else { + this.setData({ error: res.error || '加载失败' }) + } + } catch (err) { + console.error('[notice-detail] 加载公告详情失败:', err) + this.setData({ error: err.message || '加载失败' }) + } finally { + this.setData({ loading: false }) + } + }, + + formatDate(dateStr) { + if (!dateStr) return '' + const date = new Date(dateStr) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` + } +}) diff --git a/pages/notices/detail/detail.json b/pages/notices/detail/detail.json new file mode 100644 index 0000000..8835af0 --- /dev/null +++ b/pages/notices/detail/detail.json @@ -0,0 +1,3 @@ +{ + "usingComponents": {} +} \ No newline at end of file diff --git a/pages/notices/detail/detail.wxml b/pages/notices/detail/detail.wxml new file mode 100644 index 0000000..e9f5653 --- /dev/null +++ b/pages/notices/detail/detail.wxml @@ -0,0 +1,32 @@ + + + + + + 返回 + + 公告详情 + + + + + + + 加载中... + + + + + {{error}} + 返回 + + + + + + + + {{notice.createdAt}} + + + diff --git a/pages/notices/detail/detail.wxss b/pages/notices/detail/detail.wxss new file mode 100644 index 0000000..eb56539 --- /dev/null +++ b/pages/notices/detail/detail.wxss @@ -0,0 +1,118 @@ +.page { + min-height: 100vh; + background-color: #f8f8f8; + display: flex; + flex-direction: column; +} + +.unified-header { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 194rpx; + background: linear-gradient(135deg, #B06AB3 0%, #9B59B6 100%); + border-bottom: none; + display: flex; + align-items: flex-end; + justify-content: space-between; + padding: 0 32rpx 20rpx; + z-index: 1000; +} + +.unified-header-left { + display: flex; + align-items: center; + gap: 8rpx; + width: 160rpx; + height: 56rpx; +} + +.unified-back-icon { + width: 56rpx; + height: 56rpx; +} + +.unified-back-text { + font-size: 34rpx; + font-weight: bold; + color: #ffffff; +} + +.unified-header-title { + font-size: 40rpx; + font-weight: bold; + color: #ffffff; + flex: 1; + text-align: center; +} + +.unified-header-right { + width: 160rpx; + height: 56rpx; +} + +.content { + flex: 1; +} + +.loading-tip, .error-tip { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding-top: 200rpx; +} + +.loading-tip text, .error-tip text { + font-size: 30rpx; + color: #999; +} + +.error-tip text:first-child { + color: #ff6b6b; + margin-bottom: 20rpx; +} + +.retry-btn { + background: linear-gradient(135deg, #B06AB3 0%, #9B59B6 100%); + color: #fff !important; + padding: 16rpx 48rpx; + border-radius: 36rpx; + font-size: 32rpx !important; +} + +.notice-content { + padding: 30rpx; +} + +.notice-body { + background: #fff; + border-radius: 20rpx; + padding: 30rpx; +} + +.notice-body rich-text { + font-size: 32rpx; + color: #666; + line-height: 1.8; +} + +.notice-body p { + margin-bottom: 16rpx; +} + +.notice-body h3 { + font-size: 32rpx; + font-weight: bold; + color: #333; + margin: 24rpx 0 16rpx; +} + +.notice-date { + font-size: 32rpx; + color: #999; + text-align: right; + margin-top: 20rpx; + padding: 0 10rpx; +} diff --git a/pages/notices/notices.js b/pages/notices/notices.js new file mode 100644 index 0000000..f4e4f96 --- /dev/null +++ b/pages/notices/notices.js @@ -0,0 +1,77 @@ +const api = require('../../utils/api') + +Page({ + data: { + notices: [], + loading: true, + error: null + }, + + onLoad() { + this.loadNotices() + }, + + onBack() { + wx.navigateBack() + }, + + async loadNotices() { + this.setData({ loading: true, error: null }) + + try { + const res = await api.common.getNotices() + console.log('[notices] 公告列表响应:', res) + + if (res.success && res.data) { + const notices = res.data.map(item => ({ + id: item.id, + content: item.content, + linkType: item.linkType || 'none', + linkValue: item.linkValue || '', + sortOrder: item.sortOrder || 0, + createdAt: this.formatDate(item.createdAt) + })) + this.setData({ notices }) + } else { + this.setData({ error: res.error || '加载失败' }) + } + } catch (err) { + console.error('[notices] 加载公告失败:', err) + this.setData({ error: err.message || '加载失败' }) + } finally { + this.setData({ loading: false }) + } + }, + + onNoticeTap(e) { + const notice = e.currentTarget.dataset.notice + console.log('[notices] 点击公告:', notice) + + if (notice.linkType === 'web' && notice.linkValue) { + wx.navigateTo({ + url: `/pages/webview/webview?url=${encodeURIComponent(notice.linkValue)}` + }) + } else if (notice.linkType === 'miniprogram' && notice.linkValue) { + wx.navigateToMiniProgram({ + appId: notice.linkValue + }) + } else if (notice.linkType === 'article' && notice.linkValue) { + wx.navigateTo({ + url: `/pages/academy/detail/detail?id=${notice.linkValue}` + }) + } else { + wx.navigateTo({ + url: `/pages/notices/detail/detail?id=${notice.id}` + }) + } + }, + + formatDate(dateStr) { + if (!dateStr) return '' + const date = new Date(dateStr) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` + } +}) diff --git a/pages/notices/notices.json b/pages/notices/notices.json new file mode 100644 index 0000000..8835af0 --- /dev/null +++ b/pages/notices/notices.json @@ -0,0 +1,3 @@ +{ + "usingComponents": {} +} \ No newline at end of file diff --git a/pages/notices/notices.wxml b/pages/notices/notices.wxml new file mode 100644 index 0000000..5beec10 --- /dev/null +++ b/pages/notices/notices.wxml @@ -0,0 +1,43 @@ + + + + + + 返回 + + 公告 + + + + + + + 加载中... + + + + + {{error}} + 点击重试 + + + + + + + + + + 点击查看详情 > + + {{item.createdAt}} + + + + + + + 暂无公告 + + + diff --git a/pages/notices/notices.wxss b/pages/notices/notices.wxss new file mode 100644 index 0000000..041d13d --- /dev/null +++ b/pages/notices/notices.wxss @@ -0,0 +1,140 @@ +.page { + min-height: 100vh; + background-color: #f8f8f8; + display: flex; + flex-direction: column; +} + +.unified-header { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 194rpx; + background: linear-gradient(135deg, #B06AB3 0%, #9B59B6 100%); + border-bottom: none; + display: flex; + align-items: flex-end; + justify-content: space-between; + padding: 0 32rpx 20rpx; + z-index: 1000; +} + +.unified-header-left { + display: flex; + align-items: center; + gap: 8rpx; + width: 160rpx; + height: 56rpx; +} + +.unified-back-icon { + width: 56rpx; + height: 56rpx; +} + +.unified-back-text { + font-size: 34rpx; + font-weight: bold; + color: #ffffff; +} + +.unified-header-title { + font-size: 40rpx; + font-weight: bold; + color: #ffffff; + flex: 1; + text-align: center; +} + +.unified-header-right { + width: 160rpx; + height: 56rpx; +} + +.content { + flex: 1; +} + +.loading-tip, .error-tip, .empty-tip { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding-top: 200rpx; +} + +.loading-tip text, .empty-tip text, .error-tip text { + font-size: 28rpx; + color: #999; +} + +.error-tip text:first-child { + color: #ff6b6b; + margin-bottom: 20rpx; +} + +.retry-btn { + background: linear-gradient(135deg, #B06AB3 0%, #9B59B6 100%); + color: #fff !important; + padding: 16rpx 48rpx; + border-radius: 36rpx; + font-size: 28rpx !important; +} + +.empty-tip image { + width: 240rpx; + height: 240rpx; + margin-bottom: 20rpx; +} + +.notices-list { + padding: 30rpx; +} + +.notice-item { + background: #fff; + border-radius: 20rpx; + padding: 30rpx; + margin-bottom: 20rpx; + box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05); +} + +.notice-content { + font-size: 32rpx; + color: #333; + line-height: 1.8; +} + +.notice-content rich-text { + line-height: 1.8; +} + +.notice-content p { + margin-bottom: 16rpx; +} + +.notice-content h3 { + font-size: 36rpx; + font-weight: bold; + color: #333; + margin: 24rpx 0 16rpx; +} + +.notice-footer { + margin-top: 20rpx; + padding-top: 20rpx; + border-top: 2rpx solid #f0f0f0; +} + +.link-hint { + font-size: 30rpx; + color: #B06AB3; +} + +.notice-date { + font-size: 30rpx; + color: #999; + margin-top: 16rpx; + text-align: right; +} diff --git a/pages/order-detail/order-detail.js b/pages/order-detail/order-detail.js new file mode 100644 index 0000000..1b1f8ae --- /dev/null +++ b/pages/order-detail/order-detail.js @@ -0,0 +1,331 @@ +// pages/order-detail/order-detail.js +// 订单详情页面 - 对接后端API + +const api = require('../../utils/api') + +// 评价标签预设 +const REVIEW_TAGS = [ + '专业', '耐心', '温暖', '有效果', '善于倾听', + '有同理心', '高效', '支持', '不评判', '细心', + '温柔', '有帮助', '值得信赖', '回复及时' +] + +Page({ + data: { + statusBarHeight: 44, + navBarHeight: 44, + totalNavHeight: 88, + loading: true, + order: null, + // 评价弹窗相关 + showReviewModal: false, + reviewRating: 5, + reviewContent: '', + reviewTags: REVIEW_TAGS, + selectedTags: [], + isAnonymous: false, + submittingReview: false + }, + + onLoad(options) { + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + const menuButton = wx.getMenuButtonBoundingClientRect() + const navBarHeight = menuButton.height + (menuButton.top - statusBarHeight) * 2 + const totalNavHeight = statusBarHeight + navBarHeight + + const orderId = options.id + + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight + }) + + if (orderId) { + this.loadOrderDetail(orderId) + } else { + wx.showToast({ title: '参数错误', icon: 'none' }) + setTimeout(() => wx.navigateBack(), 1500) + } + }, + + /** + * 加载订单详情 + */ + async loadOrderDetail(id) { + this.setData({ loading: true }) + + try { + const res = await api.order.getDetail(id) + + if (res.success && res.data) { + const order = this.transformOrder(res.data) + this.setData({ order, loading: false }) + } else { + throw new Error(res.message || '加载失败') + } + } catch (err) { + console.error('加载订单详情失败', err) + this.setData({ loading: false }) + wx.showToast({ title: '加载失败', icon: 'none' }) + } + }, + + /** + * 转换订单数据格式 + */ + transformOrder(data) { + const typeConfig = { + recharge: { typeName: '爱心充值', icon: '/images/icon-heart.png', iconBg: 'pink' }, + vip: { typeName: '会员服务', icon: '/images/icon-star.png', iconBg: 'purple' }, + gift: { typeName: '礼物购买', icon: '/images/icon-gift.png', iconBg: 'orange' }, + companion: { typeName: '陪聊服务', icon: '/images/icon-chat.png', iconBg: 'blue' } + } + + const config = typeConfig[data.type] || typeConfig.recharge + + const statusMap = { + pending: '待支付', + paid: '交易成功', + completed: '交易成功', + cancelled: '已取消', + refunded: '已退款', + in_service: '服务中' + } + + const statusDescMap = { + pending: '请尽快完成支付', + paid: '您的订单已完成支付', + completed: '订单已完成', + cancelled: '订单已取消', + refunded: '订单已退款', + in_service: '服务进行中' + } + + return { + id: data.id, + typeName: config.typeName, + icon: config.icon, + iconBg: config.iconBg, + status: statusMap[data.status] || data.status, + statusDesc: statusDescMap[data.status] || '', + productName: data.product_name || data.description || '', + priceSymbol: data.currency === 'flower' ? '🌿' : '¥', + price: data.amount || data.price || '0', + payMethod: data.payment_method === 'wechat' ? '微信支付' : (data.payment_method === 'flower' ? '爱心支付' : data.payment_method || ''), + orderNo: data.order_no || data.id, + createTime: this.formatTime(data.created_at), + payTime: data.paid_at ? this.formatTime(data.paid_at) : '', + canRefund: data.status === 'paid' && data.can_refund !== false, + canCancel: data.status === 'pending', + canReview: data.status === 'completed' && !data.reviewed + } + }, + + /** + * 格式化时间 + */ + formatTime(dateStr) { + if (!dateStr) return '' + const date = new Date(dateStr) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const hour = String(date.getHours()).padStart(2, '0') + const minute = String(date.getMinutes()).padStart(2, '0') + const second = String(date.getSeconds()).padStart(2, '0') + return `${year}-${month}-${day} ${hour}:${minute}:${second}` + }, + + goBack() { + wx.navigateBack() + }, + + onCopyOrderNo() { + const { orderNo } = this.data.order + wx.setClipboardData({ + data: String(orderNo), + success: () => { + wx.showToast({ + title: '已复制订单号', + icon: 'success' + }) + } + }) + }, + + onContactService() { + wx.navigateTo({ + url: '/pages/service/service' + }) + }, + + /** + * 取消订单 + */ + async onCancel() { + const confirmed = await this.showConfirm('取消订单', '确定要取消订单吗?') + if (!confirmed) return + + wx.showLoading({ title: '取消中...' }) + + try { + const res = await api.order.cancel(this.data.order.id) + + wx.hideLoading() + + if (res.success) { + wx.showToast({ title: '订单已取消', icon: 'success' }) + // 刷新订单详情 + this.loadOrderDetail(this.data.order.id) + } else { + wx.showToast({ title: res.message || '取消失败', icon: 'none' }) + } + } catch (err) { + wx.hideLoading() + wx.showToast({ title: '取消失败', icon: 'none' }) + } + }, + + /** + * 申请退款 + */ + async onRefund() { + const confirmed = await this.showConfirm('申请退款', '确定要申请退款吗?') + if (!confirmed) return + + wx.showLoading({ title: '提交中...' }) + + try { + const res = await api.order.cancel(this.data.order.id, '用户申请退款') + + wx.hideLoading() + + if (res.success) { + wx.showToast({ title: '退款申请已提交', icon: 'success' }) + this.loadOrderDetail(this.data.order.id) + } else { + wx.showToast({ title: res.message || '申请失败', icon: 'none' }) + } + } catch (err) { + wx.hideLoading() + wx.showToast({ title: '申请失败', icon: 'none' }) + } + }, + + /** + * 评价订单 - 打开评价弹窗 + */ + onReview() { + this.setData({ + showReviewModal: true, + reviewRating: 5, + reviewContent: '', + selectedTags: [], + isAnonymous: false + }) + }, + + /** + * 关闭评价弹窗 + */ + closeReviewModal() { + this.setData({ showReviewModal: false }) + }, + + /** + * 选择评分 + */ + selectRating(e) { + const rating = e.currentTarget.dataset.rating + this.setData({ reviewRating: rating }) + }, + + /** + * 切换标签选择 + */ + toggleTag(e) { + const tag = e.currentTarget.dataset.tag + const { selectedTags } = this.data + const index = selectedTags.indexOf(tag) + + if (index > -1) { + selectedTags.splice(index, 1) + } else { + if (selectedTags.length < 5) { + selectedTags.push(tag) + } else { + wx.showToast({ title: '最多选择5个标签', icon: 'none' }) + return + } + } + + this.setData({ selectedTags }) + }, + + /** + * 输入评价内容 + */ + onReviewInput(e) { + this.setData({ reviewContent: e.detail.value }) + }, + + /** + * 切换匿名评价 + */ + toggleAnonymous() { + this.setData({ isAnonymous: !this.data.isAnonymous }) + }, + + /** + * 提交评价 + */ + async submitReview() { + const { order, reviewRating, reviewContent, selectedTags, isAnonymous } = this.data + + if (this.data.submittingReview) return + + this.setData({ submittingReview: true }) + wx.showLoading({ title: '提交中...' }) + + try { + const res = await api.order.review(order.id, { + rating: reviewRating, + content: reviewContent, + tags: selectedTags, + isAnonymous: isAnonymous + }) + + wx.hideLoading() + this.setData({ submittingReview: false }) + + if (res.success) { + wx.showToast({ title: '评价成功', icon: 'success' }) + this.setData({ showReviewModal: false }) + // 刷新订单详情 + this.loadOrderDetail(order.id) + } else { + wx.showToast({ title: res.message || res.error || '评价失败', icon: 'none' }) + } + } catch (err) { + wx.hideLoading() + this.setData({ submittingReview: false }) + console.error('提交评价失败', err) + wx.showToast({ title: '评价失败', icon: 'none' }) + } + }, + + /** + * 显示确认弹窗 + */ + showConfirm(title, content) { + return new Promise((resolve) => { + wx.showModal({ + title, + content, + success: (res) => resolve(res.confirm) + }) + }) + } +}) diff --git a/pages/order-detail/order-detail.json b/pages/order-detail/order-detail.json new file mode 100644 index 0000000..e90e996 --- /dev/null +++ b/pages/order-detail/order-detail.json @@ -0,0 +1,4 @@ +{ + "usingComponents": {}, + "navigationStyle": "custom" +} diff --git a/pages/order-detail/order-detail.wxml b/pages/order-detail/order-detail.wxml new file mode 100644 index 0000000..9ceadb7 --- /dev/null +++ b/pages/order-detail/order-detail.wxml @@ -0,0 +1,152 @@ + + + + + + + + + + 订单详情 + + + + + + + + + + + + {{order.status}} + {{order.statusDesc}} + + + + + 订单信息 + + + 订单类型 + {{order.typeName}} + + + + 商品名称 + {{order.productName}} + + + + 订单金额 + + {{order.priceSymbol}} + {{order.price}} + + + + + 支付方式 + {{order.payMethod}} + + + + + + 订单编号 + + + 订单号 + + {{order.orderNo}} + 复制 + + + + + 创建时间 + {{order.createTime}} + + + + 支付时间 + {{order.payTime}} + + + + + + 联系客服 + 评价订单 + 申请退款 + + + + + + + + + + 评价订单 + + × + + + + + + + + 服务评分 + + + + + + + + {{reviewRating}}分 - {{reviewRating >= 4 ? '非常满意' : (reviewRating >= 3 ? '一般' : '不满意')}} + + + + + 选择标签(最多5个) + + + {{item}} + + + + + + + 评价内容 + + {{reviewContent.length}}/500 + + + + + + + + 匿名评价 + + + + + + + + diff --git a/pages/order-detail/order-detail.wxss b/pages/order-detail/order-detail.wxss new file mode 100644 index 0000000..57e7ca7 --- /dev/null +++ b/pages/order-detail/order-detail.wxss @@ -0,0 +1,421 @@ +/* 订单详情页样式 */ +.page-container { + min-height: 100vh; + background: #faf8fc; +} + +/* 自定义导航栏 */ +.custom-nav { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + background: #faf8fc; +} + +.status-bar { + width: 100%; +} + +.nav-bar { + display: flex; + align-items: center; + justify-content: space-between; + height: 88rpx; + padding: 0 32rpx; +} + +.nav-left { + width: 80rpx; + display: flex; + align-items: center; +} + +.nav-back-icon { + width: 48rpx; + height: 48rpx; +} + +.nav-title { + flex: 1; + text-align: center; + font-size: 34rpx; + font-weight: 600; + color: #101828; +} + +.nav-right { + width: 80rpx; +} + +/* 内容区域 */ +.content { + height: 100vh; + padding: 32rpx; + box-sizing: border-box; +} + +/* 订单状态卡片 */ +.status-card { + background: #fff; + border-radius: 32rpx; + padding: 48rpx 32rpx; + margin-bottom: 24rpx; + display: flex; + flex-direction: column; + align-items: center; + box-shadow: 0 2rpx 6rpx rgba(0,0,0,0.08); +} + +.status-icon-wrap { + width: 120rpx; + height: 120rpx; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 24rpx; +} + +.status-icon-wrap.pink { + background: #fdf2f8; +} + +.status-icon-wrap.purple { + background: #faf5ff; +} + +.status-icon-wrap.orange { + background: #fff7ed; +} + +.status-icon-wrap.green { + background: #ecfdf5; +} + +.status-icon { + width: 56rpx; + height: 56rpx; +} + +.status-title { + font-size: 40rpx; + font-weight: 700; + color: #101828; + margin-bottom: 12rpx; +} + +.status-desc { + font-size: 28rpx; + color: #6a7282; +} + +/* 信息卡片 */ +.info-card { + background: #fff; + border-radius: 32rpx; + padding: 32rpx; + margin-bottom: 24rpx; + box-shadow: 0 2rpx 6rpx rgba(0,0,0,0.08); +} + +.card-title { + font-size: 32rpx; + font-weight: 700; + color: #101828; + margin-bottom: 24rpx; + padding-bottom: 20rpx; + border-bottom: 2rpx solid #f3f4f6; +} + +.info-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20rpx 0; +} + +.info-label { + font-size: 28rpx; + color: #6a7282; +} + +.info-value { + font-size: 28rpx; + color: #101828; + font-weight: 500; +} + +.info-price { + display: flex; + align-items: baseline; + gap: 4rpx; +} + +.info-price .price-symbol { + font-size: 28rpx; + font-weight: 700; + color: #ff4d6d; +} + +.info-price .price-value { + font-size: 36rpx; + font-weight: 900; + color: #ff4d6d; +} + +.info-copy { + display: flex; + align-items: center; + gap: 16rpx; +} + +.copy-btn { + font-size: 24rpx; + color: #ff4d6d; + padding: 8rpx 16rpx; + background: #fff0f3; + border-radius: 12rpx; +} + +/* 底部按钮 */ +.bottom-actions { + display: flex; + gap: 24rpx; + padding: 32rpx 0 120rpx; +} + +.action-btn { + flex: 1; + height: 88rpx; + border-radius: 44rpx; + display: flex; + align-items: center; + justify-content: center; + font-size: 30rpx; + font-weight: 600; +} + +.action-btn.outline { + background: #fff; + border: 2rpx solid #e5e7eb; + color: #364153; +} + +.action-btn.primary { + background: #ff4d6d; + color: #fff; +} + + +/* ========== 评价弹窗样式 ========== */ +.review-modal-mask { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; +} + +.review-modal { + position: fixed; + left: 0; + right: 0; + bottom: 0; + background: #fff; + border-radius: 32rpx 32rpx 0 0; + z-index: 1001; + max-height: 85vh; + display: flex; + flex-direction: column; + animation: slideUp 0.3s ease; +} + +@keyframes slideUp { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} + +.review-modal.show { + transform: translateY(0); +} + +.review-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 32rpx; + border-bottom: 2rpx solid #f3f4f6; +} + +.review-modal-title { + font-size: 36rpx; + font-weight: 700; + color: #101828; +} + +.review-modal-close { + width: 48rpx; + height: 48rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.close-icon { + font-size: 48rpx; + color: #6a7282; + line-height: 1; +} + +.review-modal-content { + flex: 1; + padding: 32rpx; + overflow-y: auto; + max-height: 60vh; +} + +/* 评价区块 */ +.review-section { + margin-bottom: 32rpx; +} + +.section-title { + font-size: 28rpx; + font-weight: 600; + color: #101828; + margin-bottom: 16rpx; + display: block; +} + +/* 评分星星 */ +.rating-stars { + display: flex; + gap: 16rpx; + margin-bottom: 12rpx; +} + +.star-item { + font-size: 48rpx; + opacity: 0.3; + transition: all 0.2s; +} + +.star-item.active { + opacity: 1; +} + +.rating-text { + font-size: 24rpx; + color: #6a7282; +} + +/* 标签列表 */ +.tag-list { + display: flex; + flex-wrap: wrap; + gap: 16rpx; +} + +.tag-item { + padding: 12rpx 24rpx; + background: #f3f4f6; + border-radius: 100rpx; + font-size: 26rpx; + color: #4a5565; + border: 2rpx solid transparent; + transition: all 0.2s; +} + +.tag-item.active { + background: #fff0f3; + color: #ff4d6d; + border-color: #ff4d6d; +} + +/* 评价输入框 */ +.review-textarea { + width: 100%; + height: 200rpx; + padding: 24rpx; + background: #f9fafb; + border-radius: 16rpx; + font-size: 28rpx; + color: #101828; + box-sizing: border-box; +} + +.char-count { + display: block; + text-align: right; + font-size: 24rpx; + color: #99a1af; + margin-top: 8rpx; +} + +/* 匿名选项 */ +.anonymous-section { + display: flex; + align-items: center; + gap: 16rpx; + padding: 16rpx 0; +} + +.checkbox { + width: 40rpx; + height: 40rpx; + border: 2rpx solid #d1d5db; + border-radius: 8rpx; + display: flex; + align-items: center; + justify-content: center; + font-size: 24rpx; + color: #fff; + transition: all 0.2s; +} + +.checkbox.checked { + background: #ff4d6d; + border-color: #ff4d6d; +} + +.anonymous-text { + font-size: 28rpx; + color: #4a5565; +} + +/* 提交按钮 */ +.review-modal-footer { + padding: 24rpx 32rpx; + padding-bottom: calc(24rpx + env(safe-area-inset-bottom)); + border-top: 2rpx solid #f3f4f6; +} + +.submit-btn { + width: 100%; + height: 96rpx; + background: linear-gradient(to right, #ff4d6d, #e91e63); + color: #fff; + font-size: 32rpx; + font-weight: 600; + border-radius: 48rpx; + border: none; + display: flex; + align-items: center; + justify-content: center; +} + +.submit-btn.disabled { + opacity: 0.6; +} + +.submit-btn::after { + border: none; +} diff --git a/pages/orders/orders.js b/pages/orders/orders.js new file mode 100644 index 0000000..00dda03 --- /dev/null +++ b/pages/orders/orders.js @@ -0,0 +1,118 @@ +const api = require('../../utils/api'); + +Page({ + data: { + statusBarHeight: 20, + navBarHeight: 44, + totalNavHeight: 64, + loading: true, + currentTab: 'all', // all, pending, paid, completed + list: [] + }, + onLoad() { + const sys = wx.getSystemInfoSync(); + const menu = wx.getMenuButtonBoundingClientRect(); + const statusBarHeight = sys.statusBarHeight || 20; + const navBarHeight = menu.height + (menu.top - statusBarHeight) * 2; + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight: statusBarHeight + navBarHeight + }); + this.load(); + }, + onBack() { + wx.navigateBack({ delta: 1 }); + }, + async load(isRefresh = false) { + if (!isRefresh) { + this.setData({ loading: true }); + } + try { + // Use payment.getOrders to show the recharge/vip/etc orders + // Pass confirm: 1 on initial load or refresh to ensure status is up to date + const params = { page: 1, pageSize: 50, confirm: 1 }; + if (this.data.currentTab !== 'all') { + params.status = this.data.currentTab; + } + const res = await api.payment.getOrders(params); + // Get list from response structure + let orders = []; + if (res.data && Array.isArray(res.data.list)) { + orders = res.data.list; + } else if (res.data && Array.isArray(res.data.orders)) { + orders = res.data.orders; + } else if (Array.isArray(res.data)) { + orders = res.data; + } + + const list = orders.map((o) => ({ + id: o.id || o.orderNo, + remark: this.formatOrderType(o.orderType || 'order'), + amountText: this.formatAmount(o.amount), + status: this.formatStatus(o.status), + statusClass: `status-${o.status}`, + createdAtText: this.formatDateTime(new Date(o.createdAt || o.created_at || Date.now())), + // visual adjustment: ensure it looks like income/recharge style + transactionType: o.orderType || 'recharge' + })); + + this.setData({ list }); + } catch (err) { + console.error('API failed', err); + // Fallback empty list + if (!isRefresh) { + this.setData({ list: [] }); + } + } finally { + this.setData({ loading: false }); + if (isRefresh) { + wx.stopPullDownRefresh(); + } + } + }, + onPullDownRefresh() { + this.load(true); + }, + switchTab(e) { + const tab = e.currentTarget.dataset.tab; + if (tab === this.data.currentTab) return; + this.setData({ currentTab: tab }, () => { + this.load(); + }); + }, + formatOrderType(type) { + const map = { + 'recharge': '充值', + 'vip': '会员购买', + 'agent_purchase': '智能体购买', + 'companion_chat': '陪聊服务', + 'identity_card': '身份卡' + }; + return map[String(type)] || '订单'; + }, + formatStatus(status) { + const map = { + 'pending': '待支付', + 'paid': '已支付', + 'refunding': '退款中', + 'refunded': '已退款', + 'cancelled': '已取消', + 'completed': '已完成' + }; + return map[String(status)] || status; + }, + formatDateTime(d) { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + return `${y}-${m}-${day} ${hh}:${mm}`; + }, + formatAmount(amount) { + const n = Number(amount || 0); + return `¥${n.toFixed(2)}`; + } +}); + diff --git a/pages/orders/orders.json b/pages/orders/orders.json new file mode 100644 index 0000000..8fddf0b --- /dev/null +++ b/pages/orders/orders.json @@ -0,0 +1,7 @@ +{ + "usingComponents": { + "app-icon": "/components/icon/icon" + }, + "enablePullDownRefresh": true, + "backgroundTextStyle": "dark" +} diff --git a/pages/orders/orders.wxml b/pages/orders/orders.wxml new file mode 100644 index 0000000..389b8d3 --- /dev/null +++ b/pages/orders/orders.wxml @@ -0,0 +1,40 @@ + + + + + + 返回 + + 我的订单 + + + + + 全部 + 待支付 + 已支付 + 已完成 + + + + + + 加载中... + 暂无数据 + + + + {{item.remark || item.transactionType}} + {{item.createdAtText}} + + + {{item.amountText}} + {{item.status}} + + + + + + + + diff --git a/pages/orders/orders.wxss b/pages/orders/orders.wxss new file mode 100644 index 0000000..6c279fc --- /dev/null +++ b/pages/orders/orders.wxss @@ -0,0 +1,136 @@ +.page { + min-height: 100vh; + background: #E8C3D4; +} + +/* 顶部导航栏已移除,改用全局 unified-header */ + +.wrap { + padding: 0 32rpx; +} + +.tabs-fixed { + position: fixed; + left: 0; + right: 0; + height: 120rpx; + background: #ffffff; + display: flex; + align-items: center; + justify-content: space-around; + z-index: 100; + border-bottom: 2rpx solid #f3f4f6; +} + +.tab-item { + font-size: 28rpx; + color: #6b7280; + font-weight: 600; + position: relative; + height: 80rpx; + line-height: 100rpx; +} + +.tab-item.active { + color: #b06ab3; + font-weight: 900; +} + +.tab-item.active::after { + content: ''; + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 40rpx; + height: 6rpx; + background: #b06ab3; + border-radius: 3rpx; +} + +.card { + background: #ffffff; + border-radius: 40rpx; + padding: 24rpx; + box-shadow: 0 10rpx 20rpx rgba(17, 24, 39, 0.04); +} + +.loading, +.empty { + text-align: center; + color: #9ca3af; + font-weight: 800; + padding: 80rpx 0; +} + +.row { + padding: 24rpx 8rpx; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 2rpx solid #f3f4f6; +} + +.row:last-child { + border-bottom: 0; +} + +.row-left { + flex: 1; + min-width: 0; +} + +.row-title { + display: block; + font-size: 34rpx; + font-weight: 900; + color: #111827; +} + +.row-sub { + display: block; + margin-top: 10rpx; + font-size: 26rpx; + color: #9ca3af; + font-weight: 600; +} + +.row-right { + text-align: right; +} + +.row-amount { + display: block; + font-size: 36rpx; + font-weight: 900; + color: #b06ab3; +} + +.row-status { + display: block; + margin-top: 10rpx; + font-size: 26rpx; + color: #6b7280; + font-weight: 700; +} + +.status-completed { + color: #10B981; +} + +.status-paid { + color: #3B82F6; +} + +.status-pending { + color: #F59E0B; +} + +.status-cancelled { + color: #9CA3AF; +} + +.status-refunded { + color: #EF4444; +} + diff --git a/pages/outdoor-activities/outdoor-activities.js b/pages/outdoor-activities/outdoor-activities.js new file mode 100644 index 0000000..80ec686 --- /dev/null +++ b/pages/outdoor-activities/outdoor-activities.js @@ -0,0 +1,374 @@ +// pages/outdoor-activities/outdoor-activities.js - 户外郊游页面 +const api = require('../../utils/api') +const app = getApp() + +Page({ + data: { + statusBarHeight: 44, + navBarHeight: 44, + totalNavHeight: 88, + loading: false, + loadingMore: false, + activeTab: 'featured', + + // 活动列表 + activityList: [], + + // 分页相关 + page: 1, + limit: 20, + hasMore: true, + total: 0, + + // 二维码弹窗 + showQrcodeModal: false, + qrcodeImageUrl: '' + }, + + onLoad() { + // 计算导航栏高度 + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + const menuButton = wx.getMenuButtonBoundingClientRect() + const navBarHeight = menuButton.height + (menuButton.top - statusBarHeight) * 2 + const totalNavHeight = statusBarHeight + navBarHeight + + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight + }) + + this.loadActivityList() + }, + + /** + * 返回上一页 + */ + onBack() { + wx.navigateBack() + }, + + /** + * 切换活动标签 + */ + onTabChange(e) { + const tab = e.currentTarget.dataset.tab + if (tab === this.data.activeTab) return + + this.setData({ + activeTab: tab, + activityList: [], + page: 1, + hasMore: true + }) + this.loadActivityList() + }, + + /** + * 下拉刷新 + */ + onPullDownRefresh() { + this.loadActivityList(false).finally(() => { + wx.stopPullDownRefresh() + }) + }, + + /** + * 上拉加载更多 + */ + onReachBottom() { + if (this.data.hasMore && !this.data.loadingMore && !this.data.loading) { + this.loadActivityList(true) + } + }, + + /** + * 加载活动列表 - 根据categoryName筛选户外郊游(支持分页) + */ + async loadActivityList(isLoadMore = false) { + if (isLoadMore) { + this.setData({ loadingMore: true }) + } else { + this.setData({ loading: true, page: 1, hasMore: true, activityList: [] }) + } + + try { + const { activeTab, page, limit } = this.data + const params = { + category: 'outdoor', + limit: limit, + page: page + } + + if (activeTab === 'featured') { + params.tab = 'featured' + } else if (activeTab === 'free') { + params.priceType = 'free' + } else if (activeTab === 'vip') { + params.is_vip = true + } else if (activeTab === 'svip') { + params.is_svip = true + } + + const res = await api.activity.getList(params) + + if (res.success && res.data && res.data.list) { + const total = res.data.total || 0 + const allActivities = res.data.list + const outdoorActivities = allActivities.filter(item => item.categoryName === '户外郊游') + + let clubQrcode = '' + const firstWithQrcode = outdoorActivities.find(item => item.activityGuideQrcode || item.activity_guide_qrcode) + if (firstWithQrcode && !isLoadMore) { + clubQrcode = firstWithQrcode.activityGuideQrcode || firstWithQrcode.activity_guide_qrcode + } + + const newActivityList = outdoorActivities.map(item => { + const heat = item.heat || (item.likes * 2 + (item.views || 0) + (item.current_participants || 0) * 3) + + return { + id: item.id, + title: item.title, + date: this.formatDate(item.start_date || item.activityDate), + location: item.location || '', + venue: item.venue || '', + image: item.coverImage || item.cover_image || '', + heat: Math.floor(heat), + price: item.price_text || item.priceText || '免费', + priceType: item.is_free || item.priceType === 'free' ? 'free' : 'paid', + likes: item.likes || item.likesCount || 0, + participants: item.current_participants || item.currentParticipants || 0, + isLiked: item.is_liked || item.isLiked || false, + isSignedUp: item.is_registered || item.isSignedUp || false, + status: item.status || (item.currentParticipants >= item.maxParticipants && item.maxParticipants > 0 ? 'full' : 'upcoming'), + activityGuideQrcode: item.activityGuideQrcode || item.activity_guide_qrcode || '' + } + }) + + const hasMore = newActivityList.length >= limit && (this.data.activityList.length + newActivityList.length) < total + + if (isLoadMore) { + this.setData({ + activityList: [...this.data.activityList, ...newActivityList], + loadingMore: false, + hasMore, + page: this.data.page + 1, + total + }) + } else { + this.setData({ + activityList: newActivityList, + hasMore, + total, + qrcodeImageUrl: clubQrcode || this.data.qrcodeImageUrl || 'https://ai-c.maimanji.com/images/outdoor-group-qrcode.png' + }) + } + + console.log('[outdoor-activities] 加载成功,总数:', total, '当前:', this.data.activityList.length, 'hasMore:', hasMore) + } else { + if (isLoadMore) { + this.setData({ loadingMore: false, hasMore: false }) + } else { + this.setData({ activityList: [], hasMore: false }) + } + } + } catch (err) { + console.error('加载活动列表失败', err) + if (isLoadMore) { + this.setData({ loadingMore: false }) + } else { + this.setData({ activityList: [], loading: false }) + } + } finally { + if (!isLoadMore) { + this.setData({ loading: false }) + } + } + }, + + /** + * 格式化日期 + */ + formatDate(dateStr) { + if (!dateStr) return '' + const date = new Date(dateStr) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}年${month}月${day}日` + }, + + /** + * 点击活动卡片 + */ + onActivityTap(e) { + const id = e.currentTarget.dataset.id + wx.navigateTo({ + url: `/pages/activity-detail/activity-detail?id=${id}` + }) + }, + + /** + * 点赞 + */ + async onLike(e) { + const id = e.currentTarget.dataset.id + const index = e.currentTarget.dataset.index + + if (!app.globalData.isLoggedIn) { + wx.navigateTo({ url: '/pages/login/login' }) + return + } + + try { + const res = await api.activity.toggleLike(id) + if (res.success) { + this.setData({ + [`activityList[${index}].isLiked`]: res.data.isLiked, + [`activityList[${index}].likes`]: res.data.likesCount + }) + } + } catch (err) { + console.error('点赞失败', err) + wx.showToast({ title: '操作失败', icon: 'none' }) + } + }, + + /** + * 立即报名 + */ + async onSignUp(e) { + const id = e.currentTarget.dataset.id + const index = e.currentTarget.dataset.index + + if (!app.globalData.isLoggedIn) { + wx.navigateTo({ url: '/pages/login/login' }) + return + } + + const activity = this.data.activityList[index] + + // 检查活动状态 + if (activity.status === 'full' || activity.status === 'ended') { + const qrCode = activity.activityGuideQrcode || activity.activity_guide_qrcode || this.data.qrcodeImageUrl || 'https://ai-c.maimanji.com/api/common/qrcode?type=group' + this.setData({ + qrcodeImageUrl: qrCode, + showQrcodeModal: true + }) + return + } + + try { + if (activity.isSignedUp) { + // 取消报名 + const res = await api.activity.cancelSignup(id) + if (res.success) { + wx.showToast({ title: '已取消报名', icon: 'success' }) + this.loadActivityList() + } + } else { + // 报名 + const res = await api.activity.signup(id) + if (res.success) { + wx.showToast({ title: '报名成功', icon: 'success' }) + this.loadActivityList() + } else { + // 检查是否需要显示二维码(后端开关关闭或活动已结束) + if (res.code === 'QR_CODE_REQUIRED' || res.error === 'QR_CODE_REQUIRED' || res.code === 'ACTIVITY_ENDED' || res.error === '活动已结束') { + if (activity.activityGuideQrcode || activity.activity_guide_qrcode) { + this.setData({ qrcodeImageUrl: activity.activityGuideQrcode || activity.activity_guide_qrcode }) + } + this.setData({ showQrcodeModal: true }) + } else { + wx.showToast({ + title: res.error || '操作失败', + icon: 'none' + }) + } + } + } + } catch (err) { + console.error('报名操作失败', err) + // 捕获特定错误码以显示二维码 + const isQrRequired = err && (err.code === 'QR_CODE_REQUIRED' || (err.data && err.data.code === 'QR_CODE_REQUIRED')) + const isActivityEnded = err && (err.code === 'ACTIVITY_ENDED' || (err.data && err.data.code === 'ACTIVITY_ENDED') || err.error === '活动已结束') + + if (isQrRequired || isActivityEnded) { + if (activity.activityGuideQrcode || activity.activity_guide_qrcode) { + this.setData({ qrcodeImageUrl: activity.activityGuideQrcode || activity.activity_guide_qrcode }) + } + this.setData({ showQrcodeModal: true }) + + if (isActivityEnded) { + wx.showToast({ title: '活动已结束,进群查看更多', icon: 'none' }) + } + } else { + wx.showToast({ + title: err.error || err.message || '操作失败', + icon: 'none' + }) + } + } + }, + + /** + * 加入户外郊游群 + */ + onJoinGroup() { + // 如果没有二维码,尝试获取第一个活动的二维码 + if (!this.data.qrcodeImageUrl && this.data.activityList.length > 0) { + const firstWithQrcode = this.data.activityList.find(item => item.activityGuideQrcode || item.activity_guide_qrcode) + if (firstWithQrcode) { + this.setData({ qrcodeImageUrl: firstWithQrcode.activityGuideQrcode || firstWithQrcode.activity_guide_qrcode }) + } + } + this.setData({ showQrcodeModal: true }) + }, + + /** + * 关闭二维码弹窗 + */ + onCloseQrcodeModal() { + this.setData({ showQrcodeModal: false }) + }, + + /** + * 阻止冒泡 + */ + preventBubble() { + return + }, + + /** + * 保存二维码 + */ + async onSaveQrcode() { + try { + const { qrcodeImageUrl } = this.data + const downloadRes = await new Promise((resolve, reject) => { + wx.downloadFile({ + url: qrcodeImageUrl, + success: resolve, + fail: reject + }) + }) + + if (downloadRes.statusCode !== 200) throw new Error('下载失败') + + await new Promise((resolve, reject) => { + wx.saveImageToPhotosAlbum({ + filePath: downloadRes.tempFilePath, + success: resolve, + fail: reject + }) + }) + + wx.showToast({ title: '保存成功', icon: 'success' }) + this.onCloseQrcodeModal() + } catch (err) { + console.error('保存失败', err) + wx.showToast({ title: '保存失败', icon: 'none' }) + } + } +}) diff --git a/pages/outdoor-activities/outdoor-activities.json b/pages/outdoor-activities/outdoor-activities.json new file mode 100644 index 0000000..a3b4779 --- /dev/null +++ b/pages/outdoor-activities/outdoor-activities.json @@ -0,0 +1,9 @@ +{ + "navigationStyle": "custom", + "navigationBarTextStyle": "black", + "usingComponents": { + "app-icon": "../../components/icon/icon" + }, + "enablePullDownRefresh": true, + "backgroundTextStyle": "dark" +} diff --git a/pages/outdoor-activities/outdoor-activities.wxml b/pages/outdoor-activities/outdoor-activities.wxml new file mode 100644 index 0000000..d361608 --- /dev/null +++ b/pages/outdoor-activities/outdoor-activities.wxml @@ -0,0 +1,133 @@ + + + + + + + + + + 户外郊游 + + + + + + + + + 户外郊游俱乐部 + + 结伴同行 + 领略自然 + + + + 点击立即加入 + + + + + + + + + 精选活动 + + + 免费活动 + + + VIP活动 + + + SVIP活动 + + + + + + + + + + + + + + {{item.price}} + + + + {{item.title}} + + + + {{item.date}} + + + + + {{item.location}} + + + + {{item.heat}} + + + + + + + {{item.participants}}人已报名 + + + + + + + + + 暂无活动 + + + + 没有更多活动了 ~ + + + + + + + + + + + + + 加入户外郊游群 + 发现大自然之美,结交志同道合的朋友 + + + + 保存二维码 + + + diff --git a/pages/outdoor-activities/outdoor-activities.wxss b/pages/outdoor-activities/outdoor-activities.wxss new file mode 100644 index 0000000..bed3e97 --- /dev/null +++ b/pages/outdoor-activities/outdoor-activities.wxss @@ -0,0 +1,603 @@ +/* 户外郊游页面样式 - 清新绿色主题 */ +page { + background: linear-gradient(180deg, #C8E6C9 0%, #E8F5E9 100%); +} + +.page-container { + min-height: 100vh; + background: linear-gradient(180deg, #C8E6C9 0%, #E8F5E9 100%); +} + +/* 固定导航栏容器 */ +.nav-container { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 999; + background: rgba(248, 252, 249, 0.75); + backdrop-filter: blur(20rpx) saturate(180%); + -webkit-backdrop-filter: blur(20rpx) saturate(180%); +} + +/* 状态栏 */ +.status-bar { + background: transparent; +} + +/* 导航栏 */ +.nav-bar { + display: flex; + align-items: center; + justify-content: center; + padding: 0 32rpx; + background: transparent; + position: relative; +} + +.nav-back { + position: absolute; + left: 32rpx; + top: 50%; + transform: translateY(-50%); + width: 80rpx; + height: 80rpx; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +.back-icon { + width: 48rpx; + height: 48rpx; +} + +.nav-title { + font-size: 36rpx; + font-weight: 700; + color: #1A1A1A; + line-height: 1; +} + +/* 内容滚动区域 */ +.content-scroll { + height: 100vh; + box-sizing: border-box; +} + +/* 推广卡片 - 清新绿色渐变 */ +.city-group-card { + margin: 32rpx; + padding: 32rpx 40rpx; + min-height: 128rpx; + background: linear-gradient(135deg, + rgba(200, 230, 201, 0.6) 0%, + rgba(232, 245, 233, 0.6) 100%); + backdrop-filter: blur(16rpx) saturate(150%); + border: 2rpx solid rgba(76, 175, 80, 0.3); + border-radius: 48rpx; + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: 0 4rpx 20rpx rgba(76, 175, 80, 0.12); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.city-group-card:active { + transform: scale(0.98); + box-shadow: 0 2rpx 12rpx rgba(76, 175, 80, 0.18); +} + +.group-info { + flex: 1; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8rpx; + padding-right: 24rpx; +} + +.group-title { + font-size: 40rpx; + font-weight: 700; + color: #1A1A1A; + line-height: 1.4; + white-space: nowrap; +} + +.group-tags { + display: flex; + flex-direction: column; + gap: 4rpx; +} + +.tag-item { + font-size: 28rpx; + font-weight: 500; + color: #4A5565; + line-height: 1.4; + white-space: nowrap; +} + +.join-btn { + padding: 0 40rpx; + height: 88rpx; + background: linear-gradient(135deg, #66BB6A 0%, #43A047 100%); + border-radius: 100rpx; + display: flex; + align-items: center; + justify-content: center; + font-size: 32rpx; + font-weight: 700; + color: #fff; + white-space: nowrap; + flex-shrink: 0; + box-shadow: 0 6rpx 24rpx rgba(76, 175, 80, 0.4), + 0 3rpx 12rpx rgba(76, 175, 80, 0.3); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; +} + +.join-btn:active { + transform: scale(0.96); + box-shadow: 0 4rpx 16rpx rgba(76, 175, 80, 0.45); +} + +/* 活动标签切换 - 横向滚动 */ +.tab-section { + padding: 32rpx 0; + background: transparent; + margin: 0 32rpx 32rpx; + position: relative; + z-index: 1; +} + +.tab-scroll { + width: 100%; + white-space: nowrap; +} + +.tab-scroll::-webkit-scrollbar { + display: none; +} + +.tab-list { + display: inline-flex; + gap: 20rpx; + padding: 0 4rpx; +} + +.tab-item { + padding: 20rpx 48rpx; + border-radius: 100rpx; + font-size: 32rpx; + font-weight: 700; + color: #6A7282; + background: rgba(255, 255, 255, 0.6); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; + flex-shrink: 0; + white-space: nowrap; +} + +.tab-item:active { + transform: scale(0.96); +} + +.tab-item.active { + color: #fff; + background: linear-gradient(135deg, #66BB6A 0%, #43A047 100%); + box-shadow: 0 12rpx 24rpx rgba(76, 175, 80, 0.3); + transform: scale(1.02); +} + +/* 活动列表标题 */ +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 32rpx; + margin-bottom: 32rpx; +} + +.section-title { + font-size: 44rpx; + font-weight: 700; + color: #1A1A1A; +} + +.activity-count { + font-size: 28rpx; + color: #43A047; + font-weight: 500; +} + +/* 活动列表 - 毛玻璃卡片 */ +.activity-list { + padding: 0 32rpx; +} + +.activity-card { + margin-bottom: 32rpx; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(16rpx); + border-radius: 32rpx; + overflow: hidden; + box-shadow: 0 8rpx 32rpx rgba(76, 175, 80, 0.12), + 0 4rpx 16rpx rgba(76, 175, 80, 0.08); + border: 1rpx solid rgba(76, 175, 80, 0.15); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.activity-card:active { + transform: scale(0.98); + box-shadow: 0 4rpx 16rpx rgba(76, 175, 80, 0.15); +} + +/* 活动图片容器 */ +.activity-image-wrap { + position: relative; + width: 100%; + height: 360rpx; + overflow: hidden; + background: linear-gradient(135deg, #E8F5E9 0%, #F1F8F4 100%); +} + +.activity-image { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* 点赞徽章 */ +.like-badge { + position: absolute; + top: 24rpx; + right: 24rpx; + display: flex; + align-items: center; + gap: 8rpx; + padding: 10rpx 20rpx; + background: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(10rpx); + border-radius: 100rpx; + z-index: 10; + box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1); +} + +.like-icon { + width: 32rpx; + height: 32rpx; +} + +.like-count { + font-size: 24rpx; + color: #4A5565; + font-weight: 600; +} + +.like-badge.liked .like-count { + color: #FF5252; +} + +/* 价格标签 */ +.price-tag { + position: absolute; + bottom: 24rpx; + left: 24rpx; + padding: 10rpx 24rpx; + border-radius: 12rpx; + font-size: 24rpx; + font-weight: 700; + color: #FFFFFF; + z-index: 10; + box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.15); +} + +.price-tag.free { + background: #4CAF50; +} + +.price-tag.paid { + background: #FF9800; +} + +.location-badge { + position: absolute; + top: 24rpx; + left: 24rpx; + padding: 12rpx 24rpx; + background: rgba(27, 94, 32, 0.85); + backdrop-filter: blur(12rpx); + border-radius: 100rpx; + display: flex; + align-items: center; + gap: 8rpx; + font-size: 24rpx; + color: #FFFFFF; + font-weight: 500; + box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.15); +} + +.location-icon { + width: 24rpx; + height: 24rpx; +} + +/* 活动信息 */ +.activity-info { + padding: 40rpx; +} + +.activity-title { + font-size: 36rpx; + font-weight: 700; + color: #2E7D32; + margin-bottom: 20rpx; + line-height: 1.4; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; +} + +.activity-meta { + display: flex; + align-items: center; + gap: 32rpx; + margin-bottom: 24rpx; +} + +.meta-item { + display: flex; + align-items: center; + gap: 8rpx; + font-size: 26rpx; + color: #43A047; +} + +.meta-icon { + width: 28rpx; + height: 28rpx; +} + +.meta-text { + font-size: 26rpx; + color: #4A5565; +} + +.meta-row { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + margin-top: 8rpx; +} + +/* 活动底部 */ +.activity-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 24rpx; + border-top: 1rpx solid rgba(76, 175, 80, 0.1); +} + +.participants { + display: flex; + align-items: center; + gap: 12rpx; +} + +.avatar-stack { + display: flex; + align-items: center; +} + +.mini-avatar { + width: 48rpx; + height: 48rpx; + border-radius: 50%; + background: #E8F5E9; + border: 2rpx solid #fff; + margin-left: -12rpx; +} + +.mini-avatar:first-child { + margin-left: 0; +} + +.participant-text { + font-size: 24rpx; + color: #62748E; +} + +.heat-item { + margin-left: auto; +} + +.heat-text { + color: #FF9800; + font-weight: 600; +} + +/* 立即报名按钮 */ +.signup-btn { + width: 220rpx; + height: 72rpx; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #66BB6A 0%, #43A047 100%); + border-radius: 100rpx; + font-size: 28rpx; + font-weight: 700; + color: #FFFFFF; + box-shadow: 0 6rpx 20rpx rgba(76, 175, 80, 0.3); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + white-space: nowrap; + flex-shrink: 0; +} + +.signup-btn:active { + transform: scale(0.95); + box-shadow: 0 4rpx 12rpx rgba(76, 175, 80, 0.35); +} + +/* 空状态 */ +.empty-state { + padding: 120rpx 32rpx; + text-align: center; +} + +.empty-icon { + width: 200rpx; + height: 200rpx; + margin: 0 auto 32rpx; + opacity: 0.5; +} + +.empty-text { + font-size: 28rpx; + color: #81C784; +} + +/* 列表底部 */ +.list-footer { + padding: 40rpx 0; + text-align: center; +} + +.footer-text { + font-size: 24rpx; + color: #99A1AF; +} + +/* 二维码弹窗 */ +.qrcode-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 9999; + display: none; + align-items: center; + justify-content: center; +} + +.qrcode-modal.show { + display: flex; +} + +/* 遮罩层 */ +.modal-mask { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4rpx); +} + +/* 弹窗内容 */ +.modal-content { + position: relative; + width: 680rpx; + background: #FFFFFF; + border-radius: 64rpx; + padding: 64rpx; + box-shadow: 0 50rpx 100rpx -24rpx rgba(0, 0, 0, 0.25); + display: flex; + flex-direction: column; + align-items: center; + z-index: 1; +} + +/* 关闭按钮 */ +.close-btn { + position: absolute; + top: 32rpx; + right: 32rpx; + width: 72rpx; + height: 72rpx; + background: #F1F5F9; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s ease; +} + +.close-btn:active { + transform: scale(0.9); + background: #E2E8F0; +} + +.close-icon { + width: 40rpx; + height: 40rpx; +} + +/* 标题 */ +.modal-title { + font-size: 48rpx; + font-weight: 700; + color: #1D293D; + text-align: center; + margin-bottom: 16rpx; + line-height: 1.5; +} + +/* 副标题 */ +.modal-subtitle { + font-size: 32rpx; + color: #62748E; + text-align: center; + margin-bottom: 48rpx; + line-height: 1.5; +} + +/* 二维码容器 */ +.qrcode-container { + width: 440rpx; + height: 440rpx; + background: #F8FAFC; + border: 2rpx solid #F1F5F9; + border-radius: 40rpx; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 48rpx; + overflow: hidden; +} + +.qrcode-image { + width: 404rpx; + height: 404rpx; + border-radius: 24rpx; +} + +/* 保存按钮 */ +.save-btn { + width: 552rpx; + height: 116rpx; + background: #07C160; + border-radius: 100rpx; + display: flex; + align-items: center; + justify-content: center; + font-size: 40rpx; + font-weight: 700; + color: #FFFFFF; + box-shadow: 0 20rpx 30rpx -6rpx rgba(220, 252, 231, 1), + 0 8rpx 12rpx -8rpx rgba(220, 252, 231, 1); + transition: all 0.3s ease; +} + +.save-btn:active { + transform: scale(0.96); + box-shadow: 0 10rpx 20rpx -6rpx rgba(220, 252, 231, 1); +} diff --git a/pages/performance/performance.js b/pages/performance/performance.js new file mode 100644 index 0000000..0c6ebed --- /dev/null +++ b/pages/performance/performance.js @@ -0,0 +1,214 @@ +const api = require('../../utils/api') +const util = require('../../utils/util') + +Page({ + data: { + statusBarHeight: 20, + navHeight: 64, + loading: false, + list: [], + page: 1, + pageSize: 20, + hasMore: true, + individualPerformance: '0', + teamTotalPerformance: '0', + pendingAmount: '210.00', + levelName: '', + defaultAvatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=500&auto=format&fit=crop&q=60', + currentTab: 'individual' // 'individual' | 'team' + }, + + onLoad() { + console.log('[Performance] Page onLoad'); + this.loadStats() + this.loadRecords() + }, + + onShow() { + // 隐藏系统默认 TabBar + wx.hideTabBar({ animation: false }); + }, + + onPullDownRefresh() { + this.setData({ + page: 1, + hasMore: true, + list: [] + }, () => { + Promise.all([ + this.loadStats(), + this.loadRecords() + ]).then(() => { + wx.stopPullDownRefresh() + }) + }) + }, + + onReachBottom() { + if (this.data.loading || !this.data.hasMore) return + this.setData({ + page: this.data.page + 1 + }, () => { + this.loadRecords() + }) + }, + + async loadStats() { + try { + console.log('[Performance] Loading stats...'); + const res = await api.commission.getStats() + console.log('[Performance] Stats response:', res); + if (res.success && res.data) { + const data = res.data + // Mapping levels + const roleMap = { + 'soulmate': '心伴会员', + 'guardian': '守护会员', + 'companion': '陪伴会员', + 'listener': '倾听会员', + 'partner': '城市合伙人' + }; + + const individual = Number(data.individualPerformance || data.individual_performance || 0); + const team = Number(data.teamTotalPerformance || data.team_total_performance || 0); + + this.setData({ + individualPerformance: this.formatAmount(individual), + teamTotalPerformance: this.formatAmount(individual + team), // 总业绩 = 个人 + 团队 + pendingAmount: Number(data.pendingAmount || data.pending_amount || 0).toFixed(2), + totalCommission: Number(data.totalCommission || data.total_commission || 0).toFixed(2), + levelName: roleMap[data.distributorRole || data.role] || data.distributorRoleName || '分销会员', + isSoulmate: (data.distributorRole || data.role) === 'soulmate' + }) + } + } catch (err) { + console.error('加载统计失败', err) + } + }, + + /** + * 格式化金额,超过一万显示为“x.x万” + * @param {number|string} val + */ + formatAmount(val) { + const num = Number(val || 0); + if (num >= 10000) { + return (num / 10000).toFixed(1) + '万'; + } + return num.toFixed(2); + }, + + async loadRecords() { + if (this.data.loading) return + this.setData({ loading: true }) + + try { + console.log('[Performance] Loading records...', { page: this.data.page, scope: this.data.currentTab }); + const res = await api.commission.getRecords({ + page: this.data.page, + limit: this.data.pageSize, + scope: this.data.currentTab // Suggesting this parameter to backend + }) + console.log('[Performance] Records response:', res); + + if (res.success && res.data) { + const records = (res.data.list || res.data || []).map(record => this.transformRecord(record)) + + this.setData({ + list: this.data.page === 1 ? records : [...this.data.list, ...records], + hasMore: records.length === this.data.pageSize + }) + } + } catch (err) { + console.error('加载记录失败', err) + wx.showToast({ + title: '加载失败', + icon: 'none' + }) + } finally { + this.setData({ loading: false }) + } + }, + + switchTab(e) { + const tab = e.currentTarget.dataset.tab + if (tab === this.data.currentTab) return + + this.setData({ + currentTab: tab, + page: 1, + list: [], + hasMore: true + }, () => { + this.loadRecords() + }) + }, + + transformRecord(record) { + const dateObj = new Date(record.created_at || record.createdAt) + const fmtTime = util.formatTime(dateObj) + + let avatar = record.fromUserAvatar || record.userAvatar || record.avatar || ''; + if (avatar) { + avatar = util.getFullImageUrl(avatar); + } + + const roleMap = { + 'soulmate': '心伴会员', + 'guardian': '守护会员', + 'companion': '陪伴会员', + 'listener': '倾听会员', + 'partner': '城市合伙人' + }; + + // 优先使用API返回的中文等级名称 + const userLevel = record.fromUserRoleName || record.userRoleName || roleMap[record.fromUserRole] || roleMap[record.userRole] || record.levelText || '普通用户'; + + return { + id: record.id, + orderNo: record.orderNo || record.order_no || record.id || '---', + userName: record.fromUserName || record.userName || '匿名用户', + userAvatar: avatar || this.data.defaultAvatar, + productName: this.getOrderTypeText(record.orderType || record.type), + userLevel: userLevel, + orderAmount: record.orderAmount ? Number(record.orderAmount).toFixed(2) : (record.amount ? Number(record.amount).toFixed(2) : '0.00'), + time: fmtTime + } + }, + + onAvatarError(e) { + const index = e.currentTarget.dataset.index; + if (index !== undefined) { + const list = this.data.list; + list[index].userAvatar = '/images/default-avatar.svg'; + this.setData({ list }); + } + }, + + getOrderTypeText(type) { + const map = { + 'recharge': '充值', + 'vip': 'VIP会员', + 'identity_card': '身份卡', + 'agent_purchase': '智能体购买', + 'companion_chat': '陪聊' + } + return map[type] || '推广订单' + }, + + goTeam() { + wx.navigateTo({ + url: '/pages/team/team' + }) + }, + + onBack() { + wx.navigateBack({ + delta: 1, + fail: (err) => { + console.error('[Performance] Back failed, navigating to profile', err); + wx.switchTab({ url: '/pages/profile/profile' }); + } + }); + } +}) diff --git a/pages/performance/performance.json b/pages/performance/performance.json new file mode 100644 index 0000000..6dbede9 --- /dev/null +++ b/pages/performance/performance.json @@ -0,0 +1,6 @@ +{ + "usingComponents": { + "app-icon": "/components/icon/icon" + }, + "navigationBarTitleText": "业绩数据" +} \ No newline at end of file diff --git a/pages/performance/performance.wxml b/pages/performance/performance.wxml new file mode 100644 index 0000000..3b9e80b --- /dev/null +++ b/pages/performance/performance.wxml @@ -0,0 +1,85 @@ + + + + + + 返回 + + 业绩数据 + + + + + + + + + + + + {{levelName || '分销会员'}} + + + + + 个人业绩 + ¥{{individualPerformance || '0.00'}} + + + 总计业绩 + ¥{{teamTotalPerformance || '0.00'}} + + + + + + + + + + 个人业绩明细 + + + 团队业绩明细 + + + + + + 最近推广订单 + + + + + + {{item.userName}} + {{item.productName}} · {{item.userLevel}} + 时间: {{item.time}} + 单号: {{item.orderNo}} + + + + ¥{{item.orderAmount}} + + + + + + + 加载中... + + + 暂无推广订单 + + + 没有更多了 + + + + + + + 已显示全部数据 + + + diff --git a/pages/performance/performance.wxss b/pages/performance/performance.wxss new file mode 100644 index 0000000..ba03a8c --- /dev/null +++ b/pages/performance/performance.wxss @@ -0,0 +1,208 @@ +.page { + min-height: 100vh; + background: #E8C3D4; +} + +.container { + padding: 32rpx; +} + +/* 顶部紫色统计卡片 */ +.header-card { + background: linear-gradient(135deg, #CF91D3 0%, #B06AB3 100%); + border-radius: 40rpx; + padding: 48rpx; + color: #FFFFFF; + margin-bottom: 32rpx; + box-shadow: 0 12rpx 32rpx rgba(176, 106, 179, 0.3); +} + +.user-level-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 48rpx; +} + +.level-left { + display: flex; + align-items: center; + gap: 24rpx; +} + +.icon-bg { + width: 72rpx; + height: 72rpx; + background: rgba(255, 255, 255, 0.15); + border-radius: 20rpx; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(10rpx); +} + +.level-name { + font-size: 38rpx; + font-weight: 800; + letter-spacing: 2rpx; +} + +.stats-grid { + display: flex; + justify-content: space-between; +} + +.stat-box { + flex: 1; +} + +.stat-label { + font-size: 28rpx; + opacity: 0.85; + margin-bottom: 20rpx; + display: block; +} + +.stat-value { + font-size: 72rpx; + font-weight: 800; + letter-spacing: 2rpx; +} + +/* 白色内容卡片 */ +.content-card { + background: #FFFFFF; + border-radius: 40rpx; + padding: 32rpx; + box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.05); + margin-bottom: 32rpx; +} + +.detail-tabs { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 32rpx; + padding-bottom: 24rpx; + border-bottom: 1rpx solid #F3F4F6; +} + +.tab-item { + display: flex; + align-items: center; + gap: 12rpx; + padding: 10rpx 0; +} + +.tab-item .tab-text { + font-size: 30rpx; + color: #9CA3AF; + font-weight: 500; + transition: all 0.3s; +} + +.tab-item.active .tab-text { + font-size: 32rpx; + color: #B06AB3; + font-weight: 800; +} + +/* 订单部分 */ +.orders-section { + padding: 0; +} + +.section-title { + font-size: 30rpx; + font-weight: 800; + color: #111827; + margin-bottom: 32rpx; +} + +.order-list { + display: flex; + flex-direction: column; + gap: 24rpx; +} + +.order-item { + background: #F9FAFB; + border-radius: 28rpx; + padding: 32rpx; + display: flex; + align-items: center; + border: 2rpx solid transparent; +} + +.order-item:active { + background: #F3F4F6; +} + +.user-avatar { + width: 100rpx; + height: 100rpx; + border-radius: 24rpx; /* Matches Figma's 14px/16px border radius feel */ + background: #E5E7EB; + margin-right: 24rpx; +} + +.order-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 4rpx; +} + +.user-name { + font-size: 30rpx; + font-weight: 800; + color: #111827; +} + +.product-info { + font-size: 24rpx; + color: #9CA3AF; + font-weight: 500; +} + +.order-no { + font-size: 20rpx; + color: #D1D5DB; + font-weight: 400; + margin-top: 4rpx; +} + +.order-right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4rpx; +} + +.label { + font-size: 22rpx; + color: #9CA3AF; + font-weight: 600; +} + +.amount { + font-size: 34rpx; + font-weight: 900; + color: #B06AB3; +} + +/* 底部提示 */ +.bottom-tip { + text-align: center; + padding: 40rpx 0; + font-size: 26rpx; + color: #9CA3AF; +} + +/* 状态样式 */ +.loading-status, .empty-status, .no-more { + text-align: center; + padding: 60rpx 0; + font-size: 26rpx; + color: #9CA3AF; +} diff --git a/pages/profile/profile.js b/pages/profile/profile.js new file mode 100644 index 0000000..1b8e888 --- /dev/null +++ b/pages/profile/profile.js @@ -0,0 +1,640 @@ +const api = require('../../utils/api'); +const util = require('../../utils/util'); +const app = getApp(); + +Page({ + data: { + defaultAvatar: '/images/default-avatar.svg', + me: { + id: '', + idShort: '', + nickname: '未登录', + avatar: '', + phone: '' + }, + vip: { + levelText: '', + expireText: '' + }, + balances: { + grass: 0, + commission: '0.00' + }, + counts: { + orders: 0, + team: 0, + performance: 0 + }, + isLoggedIn: false, + isDistributor: false, + referralCode: '', + statusBarHeight: 20, + totalUnread: 0, + + // Modal States + showPromoterModal: false, + showPromoterSuccess: false, + + // 注册奖励 + showRegistrationReward: false, + registrationRewardAmount: 0, + claiming: false, + auditStatus: 0, + + // GF100 弹窗 + showGf100Popup: false, + gf100ImageUrl: '' + }, + + onShow() { + this.setData({ + statusBarHeight: wx.getSystemInfoSync().statusBarHeight, + auditStatus: app.globalData.auditStatus + }); + wx.hideTabBar({ animation: false }); + this.loadAll(); + }, + + async loadAll() { + const isLoggedIn = app.globalData.isLoggedIn || !!wx.getStorageSync('auth_token'); + this.setData({ isLoggedIn }); + + wx.showNavigationBarLoading(); + try { + if (isLoggedIn) { + await Promise.all([ + this.loadMe(), + this.loadBalance(), + this.loadCommission(), + this.loadCounts(), + this.loadUnreadCount() + ]); + this.checkRegistrationReward(); + this.checkGf100Popup(); + } else { + this.setData({ + me: { nickname: '未登录', avatar: this.data.defaultAvatar }, + balances: { grass: 0, commission: '0.00' }, + counts: { orders: 0, team: 0, performance: 0 }, + totalUnread: 0 + }); + } + } catch (err) { + console.error('Load profile failed', err); + } finally { + wx.hideNavigationBarLoading(); + } + }, + + /** + * 检查是否需要登录 + * @returns {boolean} 是否已登录 + */ + requireLogin() { + const isLoggedIn = app.globalData.isLoggedIn || !!wx.getStorageSync('auth_token'); + if (!isLoggedIn) { + wx.showModal({ + title: '提示', + content: '请先登录后再操作', + confirmText: '去登录', + success: (res) => { + if (res.confirm) { + wx.navigateTo({ + url: '/pages/login/login' + }); + } + } + }); + return false; + } + return true; + }, + + async loadMe() { + try { + // Use direct request with cache busting to ensure fresh data + const res = await api.request('/auth/me', { data: { _t: Date.now() } }); + // Correctly unwrap the data object from the response + const user = (res && res.data) ? res.data : {}; + const id = user.id || ''; + const idShort = id ? String(id).substring(0, 8).toUpperCase() : ''; + const nickname = user.nickname || user.name || '微信用户'; + + // 处理头像:如果是默认头像则不处理,否则拼接完整路径 + let avatar = user.avatar || user.avatar_url; + if (avatar) { + avatar = util.getFullImageUrl(avatar); + } else { + avatar = this.data.defaultAvatar; + } + + const phone = user.phone || user.mobile || ''; + + // Determine Display Role + const distributorRole = user.distributorRole || user.role; + let roleText = ''; + let roleClass = ''; + + const roleMap = { + 'soulmate': { text: '心伴会员', class: 'vip-soulmate' }, + 'guardian': { text: '守护会员', class: 'vip-guardian' }, + 'companion': { text: '陪伴会员', class: 'vip-companion' }, + 'listener': { text: '倾听会员', class: 'vip-listener' }, + 'partner': { text: '城市合伙人', class: 'vip-partner' } + }; + + if (user.isDistributor && roleMap[distributorRole]) { + const info = roleMap[distributorRole]; + roleText = info.text; + roleClass = info.class; + } else { + // Fallback to VIP + const vipLevel = Number(user.vip_level || 0); + if (vipLevel > 0) { + roleText = vipLevel >= 2 ? 'SVIP' : 'VIP'; + roleClass = 'vip-normal'; + } + } + + this.setData({ + me: { id, idShort, nickname, avatar, phone }, + vip: { levelText: roleText || '', levelClass: roleClass }, + isDistributor: !!user.isDistributor + }); + + // CRITICAL: Update global data and local storage to ensure avatar consistency across pages + if (app && app.setUserInfo) { + app.setUserInfo(user); + } else { + // Fallback if app.setUserInfo is not available + app.globalData.userInfo = user; + app.globalData.userId = user.id; + wx.setStorageSync('user_info', user); + } + } catch (e) { + console.error('loadMe error', e); + } + }, + + async loadBalance() { + try { + const res = await api.user.getBalance(); + const data = (res && res.data) ? res.data : res; + const balance = data.flower_balance || data.balance || 0; + this.setData({ + 'balances.grass': Number(balance) + }); + } catch (e) { + console.error('loadBalance error', e); + } + }, + + async loadCommission() { + try { + const res = await api.commission.getStats(); + const data = (res && res.data) ? res.data : res; + const commission = data.commissionBalance || data.commission_balance || 0; + // Use individualPerformance for "Performance Data" card as per documentation + const performance = data.individualPerformance || data.individual_performance || data.totalContribution || data.total_contribution || 0; + + this.setData({ + 'balances.commission': Number(commission).toFixed(2), + 'counts.performance': this.formatAmount(performance) + }); + } catch (e) { + console.error('loadCommission error', e); + } + }, + + /** + * 格式化金额,超过一万显示为“x.x万” + * @param {number|string} val + */ + formatAmount(val) { + const num = Number(val || 0); + if (num >= 10000) { + return (num / 10000).toFixed(1) + '万'; + } + return num.toFixed(2); + }, + + async loadCounts() { + try { + // 1. Fetch self orders (all statuses) + const selfOrdersRes = await api.payment.getOrders({ page: 1, pageSize: 1 }); + const selfCount = (selfOrdersRes.data && selfOrdersRes.data.total) ? selfOrdersRes.data.total : 0; + + // 2. Fetch promotion orders (records) - usually pageSize=1 is enough to get total + // Note: check backend response structure for total count + const promoRes = await api.commission.getRecords({ page: 1, pageSize: 1 }); + const promoCount = (promoRes.data && promoRes.data.total) ? promoRes.data.total : 0; + + // 3. Fetch team members count + const teamRes = await api.commission.getReferrals({ page: 1, pageSize: 1 }); + const teamCount = (teamRes.data && teamRes.data.total) ? teamRes.data.total : 0; + + this.setData({ + 'counts.orders': selfCount + promoCount, + 'counts.team': teamCount + }); + } catch (err) { + console.error('loadCounts failed', err); + // Keep existing data or set to 0 on error + // this.setData({ 'counts.orders': 0, 'counts.team': 0 }); + } + }, + + async loadUnreadCount() { + if (!this.data.isLoggedIn) { + this.setData({ totalUnread: 0 }); + return; + } + + try { + const [convRes, proactiveRes] = await Promise.all([ + api.chat.getConversations(), + api.proactiveMessage.getPending ? api.proactiveMessage.getPending() : Promise.resolve({ success: true, data: [] }) + ]); + + let totalUnread = 0; + if (convRes.success && convRes.data) { + totalUnread = convRes.data.reduce((sum, conv) => sum + (conv.unread_count || 0), 0); + } + + if (proactiveRes.success && proactiveRes.data && Array.isArray(proactiveRes.data)) { + totalUnread += proactiveRes.data.length; + } + + this.setData({ totalUnread }); + } catch (err) { + console.log('获取未读消息数失败', err); + this.setData({ totalUnread: 0 }); + } + }, + + goSettings() { + if (this.requireLogin()) { + wx.navigateTo({ url: '/pages/settings/settings' }); + } + }, + goEdit() { + if (this.requireLogin()) { + wx.navigateTo({ url: '/pages/edit-profile/edit-profile' }); + } + }, + goRecharge() { + if (this.requireLogin()) { + wx.navigateTo({ url: '/pages/recharge/recharge' }); + } + }, + goOrders() { + if (this.requireLogin()) { + wx.navigateTo({ url: '/pages/orders/orders' }); + } + }, + goTeam() { + if (this.requireLogin()) { + wx.navigateTo({ url: '/pages/team/team' }); + } + }, + goGiftShop() { + if (this.requireLogin()) { + wx.navigateTo({ url: '/pages/gift-shop/gift-shop' }); + } + }, + goBackpack() { + if (this.requireLogin()) { + wx.navigateTo({ url: '/pages/backpack/backpack' }); + } + }, + goMyActivities() { + if (this.requireLogin()) { + wx.navigateTo({ url: '/pages/my-activities/my-activities' }); + } + }, + goWithdraw() { + if (this.requireLogin()) { + wx.navigateTo({ url: '/pages/withdraw/withdraw' }); + } + }, + goCommission() { + if (this.requireLogin()) { + wx.navigateTo({ url: '/pages/commission/commission' }); + } + }, + goPerformance() { + if (this.requireLogin()) { + wx.navigateTo({ url: '/pages/performance/performance' }); + } + }, + goCooperation() { + if (this.requireLogin()) { + wx.navigateTo({ url: '/subpackages/cooperation/pages/cooperation/cooperation' }); + } + }, + goSupport() { + if (this.requireLogin()) { + wx.navigateTo({ url: '/pages/support/support' }); + } + }, + goPromote() { + if (this.requireLogin()) { + wx.navigateTo({ url: '/pages/promote/promote' }); + } + }, + + onAvatarError() { + this.setData({ + 'me.avatar': this.data.defaultAvatar + }); + }, + + async handleLogout() { + wx.showModal({ + title: '提示', + content: '确定要退出登录吗?', + success: async (res) => { + if (res.confirm) { + try { + await api.auth.logout(); + } catch (e) { + console.error('logout api error', e); + } + + wx.removeStorageSync('auth_token'); + wx.removeStorageSync('user_info'); + + app.globalData.isLoggedIn = false; + app.globalData.userInfo = null; + + this.loadAll(); + + wx.showToast({ + title: '已退出登录', + icon: 'success' + }); + } + } + }); + }, + + /** + * 检查注册奖励领取资格 + */ + async checkRegistrationReward() { + try { + if (!api.lovePoints || typeof api.lovePoints.checkRegistrationReward !== 'function') { + return; + } + // 检查注册奖励领取资格 - 增加静默处理,避免 404 时控制台报错 + const res = await api.lovePoints.checkRegistrationReward(); + if (res && res.success && res.data && res.data.eligible) { + this.setData({ + showRegistrationReward: true, + registrationRewardAmount: res.data.amount || 100 + }); + } + } catch (err) { + // 生产环境可能未部署此接口,静默处理 + if (err.code !== 404) { + console.warn('[profile] 检查注册奖励静默失败:', err.message || '接口可能未部署'); + } + } + }, + + /** + * 领取注册奖励 + */ + async onClaimReward() { + if (this.data.claiming) return; + + if (!api.lovePoints || typeof api.lovePoints.claimRegistrationReward !== 'function') { + wx.showToast({ title: '功能暂不可用', icon: 'none' }); + return; + } + + this.setData({ claiming: true }); + wx.showLoading({ title: '领取中...' }); + + try { + const res = await api.lovePoints.claimRegistrationReward(); + wx.hideLoading(); + + if (res.success) { + wx.showToast({ + title: '领取成功', + icon: 'success', + duration: 2000 + }); + + this.setData({ + showRegistrationReward: false + }); + + // 如果后端返回了免费畅聊时间,可以做提示 + if (res.data && res.data.free_chat_time) { + console.log('[profile] 获得免费畅聊时间:', res.data.free_chat_time); + setTimeout(() => { + wx.showModal({ + title: '额外奖励', + content: '恭喜获得 60 分钟免费畅聊时间,现在就去和 AI 角色聊天吧!', + confirmText: '去聊天', + success: (modalRes) => { + if (modalRes.confirm) { + wx.switchTab({ url: '/pages/index/index' }); + } + } + }); + }, 2000); + } + + // 刷新余额 + this.loadBalance(); + } else { + wx.showToast({ + title: res.message || '领取失败', + icon: 'none' + }); + } + } catch (err) { + wx.hideLoading(); + console.error('[profile] 领取注册奖励失败:', err); + wx.showToast({ + title: '网络错误,请重试', + icon: 'none' + }); + } finally { + this.setData({ claiming: false }); + } + }, + + /** + * 关闭注册奖励弹窗 + */ + closeRewardPopup() { + this.setData({ + showRegistrationReward: false + }); + }, + + /** + * 检查 GF100 弹窗状态 + */ + async checkGf100Popup() { + if (!this.data.isLoggedIn) return; + + try { + const res = await api.lovePoints.checkGf100Status(); + console.log('[profile] GF100 检查结果:', res); + if (res.success && res.data && res.data.showPopup) { + const imageUrl = res.data.imageUrl; + this.setData({ + showGf100Popup: true, + gf100ImageUrl: imageUrl ? util.getFullImageUrl(imageUrl) : '/images/gf100.png' + }); + } + } catch (err) { + console.error('[profile] 检查 GF100 弹窗失败:', err); + } + }, + + /** + * 领取 GF100 奖励 + */ + async onClaimGf100() { + if (this.data.claiming) return; + + this.setData({ claiming: true }); + wx.showLoading({ title: '领取中...', mask: true }); + + try { + const res = await api.lovePoints.claimGf100(); + wx.hideLoading(); + + if (res.success) { + wx.showToast({ + title: '领取成功', + icon: 'success', + duration: 2000 + }); + + this.setData({ + showGf100Popup: false + }); + + // 弹出获得 60 分钟免费畅聊的提示 + setTimeout(() => { + wx.showModal({ + title: '领取成功', + content: '恭喜获得 100 爱心 + 60 分钟免费畅聊时间!', + confirmText: '去聊天', + success: (modalRes) => { + if (modalRes.confirm) { + wx.switchTab({ url: '/pages/chat/chat' }) + } + } + }); + }, 500); + + // 刷新余额 + this.loadBalance(); + } else { + wx.showToast({ + title: res.message || '领取失败', + icon: 'none' + }); + } + } catch (err) { + wx.hideLoading(); + console.error('[profile] 领取 GF100 失败:', err); + wx.showToast({ + title: '网络错误,请重试', + icon: 'none' + }); + } finally { + this.setData({ claiming: false }); + } + }, + + /** + * 关闭 GF100 弹窗 + */ + closeGf100Popup() { + this.setData({ + showGf100Popup: false + }); + }, + + preventBubble() { }, + preventTouchMove() { }, + + async onLoad() { + await this.loadReferralCode() + }, + + async loadReferralCode() { + if (!this.data.isLoggedIn) return + try { + const res = await api.commission.getStats() + if (res.success && res.data) { + this.setData({ referralCode: res.data.referralCode || '' }) + } + } catch (err) { + console.error('加载推荐码失败:', err) + } + }, + + onShareAppMessage() { + const { referralCode, isDistributor } = this.data + const referralCodeParam = referralCode ? `?referralCode=${referralCode}` : '' + + this.recordShareReward() + + return { + title: isDistributor ? `我的推荐码:${referralCode},注册即可享受优惠!` : '欢迎来到心伴俱乐部 - 随时可聊 一直陪伴', + path: `/pages/index/index${referralCodeParam}`, + imageUrl: isDistributor ? '/images/share-commission.png' : '/images/icon-heart-new.png' + } + }, + + onShareTimeline() { + const { referralCode, isDistributor } = this.data + const query = referralCode ? `referralCode=${referralCode}` : '' + + this.recordShareReward() + + return { + title: isDistributor ? `推荐码:${referralCode},注册即可享受优惠!` : '心伴俱乐部 - 随时可聊 一直陪伴', + query: query, + imageUrl: isDistributor ? '/images/share-commission.png' : '/images/icon-heart-new.png' + } + }, + + /** + * 静默记录分享奖励(分享人A获得+100爱心值) + */ + async recordShareReward() { + try { + const res = await api.lovePoints.share() + console.log('[profile] 分享爱心值奖励:', res) + } catch (err) { + console.error('[profile] 记录分享奖励失败:', err) + } + }, + + // Tab Bar Switching + switchTab(e) { + const path = e.currentTarget.dataset.path; + const app = getApp(); + + if (path === '/pages/chat/chat') { + if (!app.globalData.isLoggedIn && !wx.getStorageSync('auth_token')) { + wx.navigateTo({ + url: '/pages/login/login?redirect=' + encodeURIComponent(path) + }); + return; + } + } + wx.switchTab({ url: path }); + } +}); diff --git a/pages/profile/profile.json b/pages/profile/profile.json new file mode 100644 index 0000000..3153ca5 --- /dev/null +++ b/pages/profile/profile.json @@ -0,0 +1,5 @@ +{ + "usingComponents": { + "app-icon": "/components/icon/icon" + } +} diff --git a/pages/profile/profile.wxml b/pages/profile/profile.wxml new file mode 100644 index 0000000..06f8d16 --- /dev/null +++ b/pages/profile/profile.wxml @@ -0,0 +1,326 @@ + + + + + 我的 + + + + + + + + + + + + {{me.nickname}} + + + {{vip.levelText}} + + + + + + {{me.phone}} + + + + + + + + + + 我的钱包 + + + + + + + + 我的会员 + + 心伴会员 + + + + + + + + + + 我的收益 + + 佣金明细 + + + + + + + 可提现 (元) + {{balances.commission}} + + + + + + + + + + + + + 客户管理 + + + + + + + + + + 我的团队 + + + {{counts.team}} + + + + + + + + + + + 业绩数据 + + + ¥{{counts.performance || '0.00'}} + + + + + + + + + + + + + 推广中心 + + + + + + + + + + 我的订单 + + + + + + + + + + + 我的活动 + + + + + + + + + + + 合作入驻 + + + + + + + + + + 在线客服 + + + + + + + + + + 修改资料 + + + + + + + + + + 礼品商城 + + + 去兑换 + + + + + + + + + + 退出登录 + + + + + + + + + + + 陪伴 + + + + 文娱 + + + + 服务 + + + + + + {{totalUnread}} + 99+ + + + 消息 + + + + 我的 + + + + + + + + + + + + × + + + + + + + + + + + + + + + + 我的活动 + 查看并管理您的报名记录 + + + + + 1 + + 高额佣金回报 + 每笔订单最高享 70% 分成 + + + + + 2 + + 专属身份标识 + 获得“推广合伙人”专属徽章 + + + + + 3 + + 极速提现权益 + 佣金T+1日结算,快速到账 + + + + + + + + + + + + + + + + 我的活动列表 + 正在加载您的活动信息... + + + + + + + + + + + + + + 您的活动申请正在处理中 + 如有疑问请联系客服查询 + + + + + + + diff --git a/pages/profile/profile.wxss b/pages/profile/profile.wxss new file mode 100644 index 0000000..4ea4400 --- /dev/null +++ b/pages/profile/profile.wxss @@ -0,0 +1,783 @@ +page { + background: #E8C3D4; + min-height: 100vh; + box-sizing: border-box; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; +} + +.page { + padding-top: 194rpx; + padding-bottom: 200rpx; /* Extra space for TabBar */ +} + +/* Custom Nav已移除,改用全局 unified-header */ + +.btn-reset { + padding: 0; + margin: 0; + line-height: inherit; + background: transparent; + border-radius: 0; + text-align: center; + width: auto !important; + min-width: 0 !important; +} +.btn-reset::after { border: none; } + +/* Common Card Styles */ +.section-wrapper { + padding: 0 32rpx; + margin-bottom: 32rpx; +} + +.white-card { + background: #FFFFFF; + border-radius: 48rpx; + padding: 42rpx; + box-shadow: 0 2rpx 4rpx -2rpx rgba(0, 0, 0, 0.1), 0 2rpx 6rpx 0 rgba(0, 0, 0, 0.1); + border: 2rpx solid #F9FAFB; +} + +.main-title { + font-size: 48rpx; + font-weight: 700; + color: #101828; +} + +.card-title-row { + margin-bottom: 32rpx; + display: flex; + justify-content: space-between; + align-items: center; +} + +.border-bottom { + border-bottom: 2rpx solid #F9FAFB; + padding-bottom: 2rpx; + margin-bottom: 40rpx; +} + +/* Header Profile */ +.profile-outer { + padding: 16rpx 32rpx 32rpx; +} + +.profile-card { + background: #FFFFFF; + border-radius: 48rpx; + padding: 40rpx; + display: flex; + align-items: center; + gap: 32rpx; + box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.02); + border: 2rpx solid #F9FAFB; +} + +.avatar-wrap { + width: 140rpx; + height: 140rpx; + border-radius: 50%; + border: 4rpx solid #F3D1EA; + padding: 4rpx; + flex-shrink: 0; +} + +.avatar { + width: 100%; + height: 100%; + border-radius: 50%; +} + +.profile-info { + flex: 1; + overflow: hidden; +} + +.name-row { + display: flex; + align-items: center; + gap: 24rpx; + margin-bottom: 16rpx; +} + +.nickname { + font-size: 40rpx; + font-weight: 900; + color: #111827; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vip-badge { + background: linear-gradient(90deg, #B06AB3, #8E44AD); /* Default/fallback */ + padding: 4rpx 20rpx; + border-radius: 999rpx; + display: flex; + align-items: center; + gap: 8rpx; + box-shadow: 0 2rpx 4rpx rgba(0,0,0,0.1); +} + +.vip-badge.vip-soulmate { + background: linear-gradient(90deg, #F472B6, #991B1B); /* Pink to Deep Red */ +} + +.vip-badge.vip-guardian { + background: linear-gradient(90deg, #FFD700, #DAA520); /* Gold */ +} + +.vip-badge.vip-companion { + background: linear-gradient(90deg, #9B59B6, #8E44AD); /* Purple (Similar to main, maybe slightly distinct) -> Let's try Deep Blue/Purple */ + background: linear-gradient(90deg, #6C5CE7, #a29bfe); /* Soft Royal Blue/Purple */ +} + +.vip-badge.vip-listener { + background: linear-gradient(90deg, #2ecc71, #27ae60); /* Green */ +} + +.vip-badge.vip-partner { + background: linear-gradient(90deg, #E84393, #D63031); /* Pink/Red */ +} + +.vip-badge.vip-normal { + background: linear-gradient(90deg, #B06AB3, #8E44AD); /* Standard VIP Color */ +} + +.vip-text { + color: #FFFFFF; + font-size: 24rpx; + font-weight: 700; +} + +.id-row { + display: flex; + align-items: center; + gap: 12rpx; + color: #9CA3AF; + font-size: 28rpx; + font-weight: 500; +} + +/* Wallet Section Specifics */ +.wallet-stack { + display: flex; + flex-direction: column; + gap: 24rpx; +} + +.inner-card { + border-radius: 32rpx; + padding: 40rpx; + position: relative; + overflow: hidden; + width: 100%; + box-sizing: border-box; +} + +/* VIP Inner Card */ +.vip-card { + background: linear-gradient(180deg, #EEB8DD, #D489BE); + box-shadow: 0 10rpx 15rpx -3rpx rgba(238, 184, 221, 0.3); + display: flex; + align-items: center; + justify-content: space-between; + height: 200rpx; + overflow: hidden; +} + +.vip-content { + position: relative; + z-index: 2; +} + +.vip-header-row { + display: flex; + align-items: center; + gap: 16rpx; + margin-bottom: 4rpx; + opacity: 0.95; +} + +.vip-label { + color: #FFFFFF; + font-size: 28rpx; + font-weight: 700; +} + +.vip-main-text { + color: #FFFFFF; + font-size: 44rpx; + font-weight: 900; + letter-spacing: -1rpx; +} + +.vip-action-btn { + background: rgba(0, 0, 0, 0.15); + color: #FFFFFF; + padding: 16rpx 0; + border-radius: 999rpx; + font-size: 28rpx; + font-weight: 800; + box-shadow: 0 2rpx 4rpx rgba(0,0,0,0.1); + position: relative; + z-index: 2; + width: 160rpx !important; + margin-left: auto; + margin-right: 0 !important; +} + +.vip-card::after { + content: ''; + position: absolute; + bottom: -20rpx; + right: -20rpx; + width: 140rpx; + height: 140rpx; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9H4.5a2.5 2.5 0 0 1 0-5H6'/%3E%3Cpath d='M18 9h1.5a2.5 2.5 0 0 0 0-5H18'/%3E%3Cpath d='M4 22h16'/%3E%3Cpath d='M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22'/%3E%3Cpath d='M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22'/%3E%3Cpath d='M18 2H6v7a6 6 0 0 0 12 0V2Z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-size: contain; + opacity: 0.15; + transform: rotate(-15deg); + pointer-events: none; +} + +/* Earnings Inner Card */ +.earnings-card { + background: #FFFFFF; + border: 2rpx solid #F3F4F6; +} + +.earnings-header-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 32rpx; +} + +.earnings-title { + font-size: 32rpx; + font-weight: 700; + color: #111827; +} + +.commission-btn { + background: #FDF4F9; + padding: 12rpx 24rpx; + border-radius: 999rpx; + display: flex; + align-items: center; + gap: 4rpx; + color: #B06AB3; + font-size: 26rpx; + font-weight: 700; +} + +.earnings-body-row { + display: flex; + justify-content: space-between; + align-items: flex-end; + width: 100%; +} + +.earnings-info { + display: flex; + flex-direction: column; +} + +.earnings-label { + font-size: 24rpx; + color: #9CA3AF; + font-weight: 500; + margin-bottom: 8rpx; +} + +.earnings-amount { + font-size: 60rpx; + font-weight: 900; + color: #B06AB3; + line-height: 1; +} + +.withdraw-btn { + background: linear-gradient(135deg, #B06AB3 0%, #9B4D9E 100%); + color: #FFFFFF; + height: 72rpx; + border-radius: 999rpx; + font-size: 28rpx; + font-weight: 800; + box-shadow: 0 16rpx 32rpx rgba(176, 106, 179, 0.25); + width: 160rpx !important; + margin-left: auto; + margin-right: 0 !important; + display: flex; + align-items: center; + justify-content: center; +} + +/* Stats Grid New Design */ +.stats-grid { + display: flex; + width: 100%; + box-sizing: border-box; +} + +.stats-grid .stat-card + .stat-card { + margin-left: 32rpx; +} + +.stat-card { + flex: 1; + min-width: 0; + border-radius: 32rpx; + padding: 42rpx 42rpx 36rpx; + background: linear-gradient(180deg, #FDF4F9 0%, #FFF0F5 100%); + border: 2rpx solid rgba(243, 209, 234, 0.5); + position: relative; + box-sizing: border-box; +} + +.card-top { + display: flex; + align-items: center; + gap: 3rpx; + margin-bottom: 14rpx; +} + +.icon-wrap-sm { + width: 50rpx; + height: 50rpx; + background: rgba(255, 255, 255, 0.8); + border-radius: 28rpx; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2rpx 4rpx -2rpx rgba(0, 0, 0, 0.1), 0 2rpx 6rpx 0 rgba(0, 0, 0, 0.1); +} + +.card-name { + font-size: 36rpx; + font-weight: 900; + color: #101828; +} + +.card-bottom { + display: flex; + align-items: baseline; + gap: 8rpx; +} + +.card-num { + font-size: 50rpx; + font-weight: 900; + color: #B06AB3; + line-height: 1; +} + +.card-unit { + font-size: 28rpx; + font-weight: 700; + color: #6A7282; +} + +.card-icon-img { + width: 40rpx; + height: 40rpx; +} + +/* Menu List */ +.menu-card { + padding: 24rpx 0; +} + +.menu-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 32rpx 48rpx; +} + +.menu-divider { + height: 2rpx; + background: #F9FAFB; + margin: 0 40rpx; +} + +.menu-left { + display: flex; + align-items: center; + gap: 32rpx; +} + +.menu-text { + font-size: 32rpx; + font-weight: 700; + color: #374151; +} + +.menu-right { + display: flex; + align-items: center; + gap: 16rpx; +} + +.tag-pink { + background: #FDF4F9; + color: #B06AB3; + font-size: 24rpx; + font-weight: 700; + padding: 8rpx 24rpx; + border-radius: 999rpx; +} + +.safe-bottom-spacer { + height: 200rpx; +} + +/* 自定义底部导航栏 */ +.custom-tabbar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 194rpx; + background: #fff; + display: flex; + align-items: flex-start; + justify-content: space-around; + padding-top: 24rpx; + z-index: 999; + border-top: 2rpx solid #F3F4F6; +} + +.tabbar-item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12rpx; + width: 150rpx; + height: 120rpx; +} + +.tabbar-icon { + width: 68rpx; + height: 68rpx; +} + +.tabbar-text { + font-family: Arial, sans-serif; + font-size: 40rpx; + font-weight: 700; + color: #A58AA5; + line-height: 1; +} + +.tabbar-text.active { + color: #B06AB3; +} + +.message-icon-wrapper { + position: relative; + width: 68rpx; + height: 68rpx; +} + +.message-icon-wrapper .tabbar-icon { + width: 68rpx; + height: 68rpx; +} + +.message-badge { + position: absolute; + top: -10rpx; + right: -16rpx; + min-width: 36rpx; + height: 36rpx; + padding: 0 8rpx; + background: #FB2C36; + border: 3rpx solid #fff; + border-radius: 18rpx; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; +} + +.message-badge text { + font-size: 22rpx; + font-weight: 600; + color: #fff; + line-height: 1; +} + +/* ==================== 弹窗样式 ==================== */ +.modal-mask { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.6); + backdrop-filter: blur(8rpx); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 40rpx; +} + +.promoter-modal { + width: 100%; + max-width: 640rpx; + background: #FFFFFF; + border-radius: 48rpx; + padding: 48rpx; + position: relative; + animation: modalPop 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes modalPop { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } +} + +.modal-close-btn { + position: absolute; + top: 32rpx; + right: 32rpx; + padding: 16rpx; + z-index: 10; +} + +.modal-header { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 48rpx; +} + +.modal-icon-bg { + width: 128rpx; + height: 128rpx; + background: #FDF4F9; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 32rpx; +} + +.modal-title { + font-size: 40rpx; + font-weight: 900; + color: #111827; + margin-bottom: 12rpx; +} + +.modal-subtitle { + font-size: 28rpx; + color: #6B7280; + font-weight: 600; +} + +.benefits-list { + display: flex; + flex-direction: column; + gap: 24rpx; + margin-bottom: 48rpx; +} + +.benefit-item { + display: flex; + align-items: flex-start; + gap: 24rpx; + padding: 32rpx; + background: #F9FAFB; + border-radius: 32rpx; + border: 2rpx solid #F3F4F6; +} + +.benefit-num { + width: 48rpx; + height: 48rpx; + background: #B06AB3; + color: #FFFFFF; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 28rpx; + font-weight: 800; + flex-shrink: 0; + margin-top: 4rpx; +} + +.benefit-info { + display: flex; + flex-direction: column; + gap: 8rpx; +} + +.benefit-title { + font-size: 32rpx; + font-weight: 800; + color: #111827; +} + +.benefit-desc { + font-size: 28rpx; + color: #4B5563; + line-height: 1.4; +} + +.apply-btn { + width: 100%; + background: #B06AB3; + padding: 32rpx 0; + border-radius: 32rpx; + display: flex; + flex-direction: column; + align-items: center; + gap: 8rpx; + box-shadow: 0 16rpx 32rpx rgba(176, 106, 179, 0.25); +} + +.apply-text { + font-size: 34rpx; + font-weight: 800; + color: #FFFFFF; + letter-spacing: 2rpx; +} + +.apply-subtext { + font-size: 24rpx; + color: rgba(255,255,255,0.9); + font-weight: 600; +} + +/* Success Modal Specifics */ +.success-modal .qr-container { + display: flex; + justify-content: center; + margin-bottom: 32rpx; +} + +.qr-placeholder { + padding: 32rpx; + border: 8rpx solid #B06AB3; + border-radius: 40rpx; + background: #FFFFFF; +} + +.qr-grid { + width: 300rpx; + height: 300rpx; + background: #F3F4F6; + border-radius: 24rpx; + display: grid; + grid-template-columns: repeat(8, 1fr); + gap: 8rpx; + padding: 24rpx; + position: relative; + overflow: hidden; +} + +.qr-dot { + width: 100%; + height: 100%; + border-radius: 4rpx; +} + +.qr-logo { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.qr-logo app-icon { + background: #FFFFFF; + border-radius: 16rpx; + padding: 12rpx; + box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.1); +} + +.success-desc { + text-align: center; + color: #9CA3AF; + font-size: 28rpx; + line-height: 1.6; + margin-bottom: 48rpx; + display: flex; + flex-direction: column; +} + +.close-btn { + width: 100%; + background: #B06AB3; + padding: 28rpx 0; + border-radius: 24rpx; + font-size: 32rpx; + font-weight: 800; + color: #FFFFFF; + box-shadow: 0 8rpx 24rpx rgba(176, 106, 179, 0.2); +} + +/* ==================== GF100 弹窗样式 ==================== */ +.gf100-mask { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.75); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; + backdrop-filter: blur(5px); +} + +.gf100-content { + position: relative; + width: 62.5%; + max-width: 480rpx; + animation: gf100In 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +@keyframes gf100In { + from { + transform: scale(0.5); + opacity: 0; + } + to { + transform: scale(1); + opacity: 1; + } +} + +.gf100-image { + width: 100%; + display: block; + border-radius: 20rpx; + box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.5); +} + +.gf100-close { + position: absolute; + top: -80rpx; + right: 0; + width: 60rpx; + height: 60rpx; + border: 2rpx solid #fff; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.close-icon { + color: #fff; + font-size: 40rpx; + line-height: 1; +} + diff --git a/pages/promote-poster/promote-poster.js b/pages/promote-poster/promote-poster.js new file mode 100644 index 0000000..1e53059 --- /dev/null +++ b/pages/promote-poster/promote-poster.js @@ -0,0 +1,247 @@ +const api = require('../../utils/api') +const app = getApp() + +Page({ + data: { + statusBarHeight: 44, + navBarHeight: 44, + totalNavHeight: 88, + posters: [], + currentPosterIndex: 0, + qrCodeUrl: '', + referralCode: '', + canvasWidth: 1080, + canvasHeight: 1920, + isLoading: true, + userInfo: null + }, + + onLoad(options) { + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + const menuButton = wx.getMenuButtonBoundingClientRect() + const navBarHeight = menuButton.height + (menuButton.top - statusBarHeight) * 2 + const totalNavHeight = statusBarHeight + navBarHeight + + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight, + userInfo: app.globalData.userInfo || wx.getStorageSync('user_info') + }) + + this.loadData(); + }, + + async loadData() { + try { + // 1. 获取推荐码 + const statsRes = await api.commission.getStats(); + let referralCode = 'default'; + if (statsRes.success && statsRes.data) { + referralCode = statsRes.data.referralCode; + this.setData({ referralCode }); + } + + // 2. 设置小程序二维码地址 + // scene 格式必须为 r=XXX 才能被 app.js 正确解析 + const baseUrl = app.globalData.baseUrl || 'https://ai-c.maimanji.com'; + const qrCodeUrl = `${baseUrl}/api/user/qrcode?scene=r=${referralCode}&page=pages/index/index`; + this.setData({ qrCodeUrl }); + + // 3. 获取动态海报背景列表 + const assetRes = await api.pageAssets.getAssets('posters'); + if (assetRes && assetRes.success && assetRes.data && assetRes.data.length > 0) { + const posters = assetRes.data.map(item => { + let url = (item.asset_url || '').trim(); + // 如果是相对路径,补充完整域名 + if (url && !url.startsWith('http')) { + url = baseUrl + (url.startsWith('/') ? '' : '/') + url; + } + return { + id: item.asset_key, + url: url, + qrBottom: 4.5, // 对应 1920 高度下的位置 + qrRight: 8 // 对应 1080 宽度下的位置 + }; + }); + this.setData({ posters, isLoading: false }); + } else { + // 兜底默认海报 + this.setData({ + posters: [ + { id: 'default', url: 'https://ai-c.maimanji.com/uploads/assets/poster-1.png', qrBottom: 4.5, qrRight: 8 } + ], + isLoading: false + }); + } + } catch (err) { + console.error('[promote-poster] loadData failed:', err); + this.setData({ isLoading: false }); + } + }, + + onPosterChange(e) { + this.setData({ currentPosterIndex: e.detail.current }) + }, + + onImageError(e) { + console.error('[promote-poster] 海报图加载失败:', e.detail.errMsg); + wx.showToast({ title: '海报背景加载失败', icon: 'none' }); + }, + + onQrError(e) { + console.error('[promote-poster] 二维码加载失败:', e.detail.errMsg); + wx.showToast({ title: '二维码加载失败', icon: 'none' }); + }, + + /** + * 下载文件辅助函数 + */ + downloadFile(url) { + return new Promise((resolve, reject) => { + wx.downloadFile({ + url, + success: res => { + if (res.statusCode === 200) resolve(res.tempFilePath); + else reject(new Error('Download failed: ' + url)); + }, + fail: err => { + console.error('Download failed:', url, err); + reject(err); + } + }); + }); + }, + + async savePoster() { + if (this.data.posters.length === 0) return; + + wx.showLoading({ title: '生成中...', mask: true }); + + try { + const template = this.data.posters[this.data.currentPosterIndex]; + + // 1. 下载资源 + const [bgPath, qrPath] = await Promise.all([ + this.downloadFile(template.url), + this.downloadFile(this.data.qrCodeUrl) + ]); + + // 2. 初始化 Canvas + const query = wx.createSelectorQuery() + query.select('#posterCanvas') + .fields({ node: true, size: true }) + .exec(async (res) => { + if (!res[0] || !res[0].node) { + wx.hideLoading(); + wx.showToast({ title: 'Canvas初始化失败', icon: 'none' }); + return; + } + + const canvas = res[0].node + const ctx = canvas.getContext('2d') + const dpr = wx.getSystemInfoSync().pixelRatio + + const canvasW = 1080; + const canvasH = 1920; + + canvas.width = canvasW * dpr + canvas.height = canvasH * dpr + ctx.scale(dpr, dpr) + + // 3. 绘制背景 + const bgImg = canvas.createImage(); + bgImg.src = bgPath; + await new Promise((resolve, reject) => { + bgImg.onload = resolve; + bgImg.onerror = reject; + }); + ctx.drawImage(bgImg, 0, 0, canvasW, canvasH); + + // 4. 绘制二维码 + const qrImg = canvas.createImage(); + qrImg.src = qrPath; + await new Promise((resolve, reject) => { + qrImg.onload = resolve; + qrImg.onerror = reject; + }); + + // 识别白框位置:根据 1080x1920 设计稿,白框在右下角 + // 二维码尺寸 260x260 + const qrW = 260; + const qrX = 720; // 1080 - 100 - 260 + const qrY = 1580; // 1920 - 80 - 260 + + // 绘制二维码背景(白框内可能需要微调,这里先绘制一个纯白背景确保清晰) + ctx.fillStyle = '#FFFFFF'; + ctx.beginPath(); + this.roundRect(ctx, qrX - 5, qrY - 5, qrW + 10, qrW + 10, 10); + ctx.fill(); + + ctx.drawImage(qrImg, qrX, qrY, qrW, qrW); + + // 6. 导出 + setTimeout(() => { + wx.canvasToTempFilePath({ + canvas: canvas, + success: (fileRes) => { + wx.saveImageToPhotosAlbum({ + filePath: fileRes.tempFilePath, + success: () => { + wx.hideLoading(); + wx.showToast({ title: '已保存到相册', icon: 'success' }) + }, + fail: (err) => { + wx.hideLoading(); + if (err.errMsg.indexOf('auth deny') !== -1) { + wx.showModal({ + title: '提示', + content: '请授权保存图片到相册', + success: (sm) => { + if (sm.confirm) wx.openSetting(); + } + }); + } else { + wx.showToast({ title: '保存失败', icon: 'none' }) + } + } + }) + }, + fail: (err) => { + console.error('canvasToTempFilePath fail:', err); + wx.hideLoading(); + wx.showToast({ title: '生成图片失败', icon: 'none' }); + } + }) + }, 300); + }) + } catch (err) { + console.error('[promote-poster] savePoster error:', err); + wx.hideLoading(); + wx.showToast({ title: '海报资源加载失败', icon: 'none' }); + } + }, + + roundRect(ctx, x, y, w, h, r) { + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + }, + + onShareAppMessage() { + const title = "发现一个超赞的AI情感陪伴官,快来看看吧!"; + const imageUrl = this.data.posters[this.data.currentPosterIndex]?.url || ''; + return { + title: title, + path: `/pages/index/index?referralCode=${this.data.referralCode}`, + imageUrl: imageUrl + } + }, + + goBack() { + wx.navigateBack(); + } +}); diff --git a/pages/promote-poster/promote-poster.json b/pages/promote-poster/promote-poster.json new file mode 100644 index 0000000..8835af0 --- /dev/null +++ b/pages/promote-poster/promote-poster.json @@ -0,0 +1,3 @@ +{ + "usingComponents": {} +} \ No newline at end of file diff --git a/pages/promote-poster/promote-poster.wxml b/pages/promote-poster/promote-poster.wxml new file mode 100644 index 0000000..6c25ee8 --- /dev/null +++ b/pages/promote-poster/promote-poster.wxml @@ -0,0 +1,39 @@ + + + + + + 返回 + + 生成推广海报 + + + + + + + + + + + + + + + + + + + + + + + + + 正在加载素材... + + + + + + diff --git a/pages/promote-poster/promote-poster.wxss b/pages/promote-poster/promote-poster.wxss new file mode 100644 index 0000000..03f36ce --- /dev/null +++ b/pages/promote-poster/promote-poster.wxss @@ -0,0 +1,117 @@ +.page { + min-height: 100vh; + background-color: #F8F9FC; + display: flex; + flex-direction: column; +} + +.content { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding-bottom: 60rpx; +} + +.poster-swiper { + width: 100%; + height: 1000rpx; + margin-top: 40rpx; +} + +.poster-item { + display: flex; + justify-content: center; + align-items: center; +} + +.poster-card { + width: 540rpx; + height: 960rpx; + border-radius: 20rpx; + overflow: hidden; + position: relative; + transform: scale(0.9); + transition: all 0.3s ease; + box-shadow: 0 10rpx 30rpx rgba(0,0,0,0.1); +} + +.poster-card.active { + transform: scale(1); + box-shadow: 0 20rpx 40rpx rgba(176, 106, 179, 0.3); +} + +.poster-img { + width: 100%; + height: 100%; + background: #eee; +} + +.qr-overlay { + position: absolute; + width: 130rpx; + height: 130rpx; + background: #fff; + padding: 10rpx; + border-radius: 12rpx; +} + +.qr-img { + width: 100%; + height: 100%; +} + +.action-area { + margin-top: 60rpx; + width: 80%; + display: flex; + flex-direction: column; + gap: 24rpx; +} + +.save-btn { + background: linear-gradient(135deg, #CF91D3 0%, #B06AB3 100%); + color: white; + font-weight: 600; + border-radius: 50rpx; + width: 100%; + padding: 24rpx 0; + font-size: 30rpx; +} + +.share-btn { + background: white; + color: #B06AB3; + font-weight: 600; + border-radius: 50rpx; + width: 100%; + padding: 24rpx 0; + font-size: 30rpx; + border: 2rpx solid #B06AB3; +} + +.loading-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 100rpx 0; + color: #9ca3af; + font-size: 28rpx; +} + +.loading-spinner { + width: 60rpx; + height: 60rpx; + border: 6rpx solid #f3f3f3; + border-top: 6rpx solid #B06AB3; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 20rpx; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} diff --git a/pages/promote/promote.js b/pages/promote/promote.js new file mode 100644 index 0000000..a126e3a --- /dev/null +++ b/pages/promote/promote.js @@ -0,0 +1,386 @@ +// pages/promote/promote.js - 合作推广页面 + +const api = require('../../utils/api') + +Page({ + data: { + statusBarHeight: 44, + navBarHeight: 44, + totalNavHeight: 88, + showModal: false, // 控制弹窗显示 + // 推广数据 + isDistributor: false, + referralCode: '', + commissionStats: { + totalReferrals: 0, + commissionBalance: 0, + totalEarned: 0 + }, + referrals: [], // Recent referrals for grid + defaultAvatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=500&auto=format&fit=crop&q=60', + loading: false, + // 分享配置 + shareConfig: null + }, + + onLoad() { + // Calculat nav bar height + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + const menuButton = wx.getMenuButtonBoundingClientRect() + const navBarHeight = menuButton.height + (menuButton.top - statusBarHeight) * 2 + const totalNavHeight = statusBarHeight + navBarHeight + + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight + }) + + this.loadPromotionData() + this.loadShareConfig() + }, + + /** + * 加载分享配置 + */ + async loadShareConfig() { + try { + const res = await api.promotion.getShareConfig('promote') + if (res.success && res.data) { + const util = require('../../utils/util') + const shareConfig = { + ...res.data, + imageUrl: res.data.imageUrl ? util.getFullImageUrl(res.data.imageUrl) : '' + } + this.setData({ shareConfig }) + } + } catch (error) { + console.error('加载分享配置失败:', error) + } + }, + + /** + * 加载推广数据 + */ + async loadPromotionData() { + this.setData({ loading: true }) + + try { + // 1. Get Stats + const res = await api.commission.getStats() + if (res.success && res.data) { + this.setData({ + commissionStats: { + totalReferrals: res.data.totalReferrals || 0, + commissionBalance: (res.data.commissionBalance || 0).toFixed(2), + totalEarned: res.data.totalCommission || 0, + // 绑定到 WXML 中的字段 + shareCount: res.data.shareCount || 0, + registeredCount: res.data.totalReferrals || 0, + orderCount: res.data.orderCount || 0 + }, + referralCode: res.data.referralCode || '', + isDistributor: res.data.isDistributor || false + }) + } + + // 2. Get Referrals Preview (Limit 10 for list) + const refRes = await api.commission.getReferrals({ page: 1, limit: 10, sortBy: 'time' }) + if (refRes.success && refRes.data) { + const util = require('../../utils/util') + const list = (refRes.data.list || refRes.data || []).map(item => { + let avatar = item.avatar || item.avatarUrl || item.userAvatar; + if (avatar) { + avatar = util.getFullImageUrl(avatar); + } + const joinedAtRaw = item.boundAt || item.createdAt || ''; + const joinedAt = joinedAtRaw ? util.formatTime(joinedAtRaw, 'YYYY-MM-DD') : ''; + return { + userId: item.id || item.userId, + userName: item.nickname || item.userName || item.name || '未知用户', + userAvatar: avatar || this.data.defaultAvatar, + level: item.levelText || item.roleName || '普通用户', + joinedAt: joinedAt, + referralCount: item.referralCount || 0, + performance: item.totalContribution || 0 + }; + }); + this.setData({ + referrals: list + }); + } + + this.setData({ loading: false }) + } catch (error) { + console.error('加载推广数据失败:', error) + this.setData({ loading: false }) + } + }, + + onWithdraw() { + wx.navigateTo({ + url: '/pages/withdraw/withdraw' + }) + }, + + /** + * 跳转到全部推荐用户列表 + */ + goToReferrals() { + wx.navigateTo({ + url: '/pages/referrals/referrals' + }) + }, + + /** + * 返回上一页 + */ + goBack() { + wx.navigateBack({ + fail: () => { + // 如果无法返回,跳转到个人中心 + wx.switchTab({ url: '/pages/profile/profile' }) + } + }) + }, + + /** + * 显示合作推广弹窗 + */ + showPromotionModal() { + this.setData({ showModal: true }) + }, + + /** + * 关闭合作推广弹窗 + */ + closeModal() { + this.setData({ showModal: false }) + }, + + /** + * 阻止弹窗内容区域点击事件冒泡 + */ + preventClose() { + // 空函数,阻止事件冒泡 + }, + + /** + * 申请合作推广 + */ + applyPromotion() { + if (this.data.isDistributor) { + wx.showToast({ + title: '您已是分销商', + icon: 'none', + duration: 2000 + }) + return + } + + wx.showModal({ + title: '成为分销商', + content: '购买身份卡即可成为分销商,推荐好友赚佣金!', + confirmText: '了解详情', + cancelText: '暂不需要', + confirmColor: '#B06AB3', + success: (res) => { + if (res.confirm) { + // 跳转到充值页面购买身份卡 + wx.navigateTo({ + url: '/pages/recharge/recharge' + }) + } + } + }) + }, + + /** + * 跳转到佣金中心 + */ + goToCommission() { + wx.navigateTo({ + url: '/pages/commission/commission' + }) + }, + + goToPoster() { + wx.navigateTo({ + url: '/pages/promote-poster/promote-poster' + }) + }, + + /** + * 预览推广素材 + */ + previewMaterial(e) { + const { index } = e.currentTarget.dataset + const material = this.data.materials[index] + + wx.previewImage({ + current: material.image, + urls: this.data.materials.map(m => m.image) + }) + }, + + /** + * 下载推广素材 + */ + downloadMaterial(e) { + const { index } = e.currentTarget.dataset + const material = this.data.materials[index] + + wx.showLoading({ title: '下载中...' }) + + wx.downloadFile({ + url: material.image, + success: (res) => { + wx.hideLoading() + if (res.statusCode === 200) { + wx.saveImageToPhotosAlbum({ + filePath: res.tempFilePath, + success: () => { + wx.showToast({ + title: '已保存到相册', + icon: 'success' + }) + }, + fail: () => { + wx.showToast({ + title: '保存失败', + icon: 'none' + }) + } + }) + } + }, + fail: () => { + wx.hideLoading() + wx.showToast({ + title: '下载失败', + icon: 'none' + }) + } + }) + }, + + /** + * 查看教程详情 + */ + viewTutorial(e) { + const { index } = e.currentTarget.dataset + const tutorial = this.data.tutorials[index] + + wx.showModal({ + title: tutorial.title, + content: tutorial.content, + showCancel: false, + confirmText: '知道了' + }) + }, + + /** + * 复制推荐码 + */ + copyReferralCode() { + if (!this.data.referralCode) { + wx.showToast({ + title: '暂无推荐码', + icon: 'none' + }) + return + } + + wx.setClipboardData({ + data: this.data.referralCode, + success: () => { + wx.showToast({ + title: '已复制推荐码', + icon: 'success' + }) + } + }) + }, + + /** + * 分享推荐码 + */ + onShareAppMessage() { + const { referralCode, isDistributor, shareConfig } = this.data + const referralCodeParam = referralCode ? `?referralCode=${referralCode}` : '' + + // 记录分享行为 + api.promotion.recordShare({ + type: 'app_message', + page: '/pages/promote/promote', + referralCode: referralCode + }).then(() => { + this.loadPromotionData() + }).catch(err => console.error('记录分享失败:', err)) + + // 如果有分享配置且是分销商,使用配置内容 + if (shareConfig && isDistributor && referralCode) { + return { + title: shareConfig.title, + path: `${shareConfig.path || '/pages/index/index'}${referralCodeParam}`, + imageUrl: shareConfig.imageUrl + } + } + + // 默认分享逻辑 + if (!isDistributor || !referralCode) { + return { + title: '心伴AI - 情感陪伴聊天机器人', + path: '/pages/index/index', + imageUrl: '/images/share-cover.jpg' + } + } + + return { + title: `我的推荐码:${referralCode},注册即可享受优惠!`, + path: `/pages/index/index?referralCode=${referralCode}`, + imageUrl: '/images/share-commission.png' + } + }, + + /** + * 分享到朋友圈 + */ + onShareTimeline() { + const { referralCode, isDistributor, shareConfig } = this.data + const query = referralCode ? `referralCode=${referralCode}` : '' + + // 记录分享行为 + api.promotion.recordShare({ + type: 'timeline', + page: '/pages/promote/promote', + referralCode: referralCode + }).then(() => { + this.loadPromotionData() + }).catch(err => console.error('记录分享失败:', err)) + + // 如果有分享配置且是分销商,使用配置内容 + if (shareConfig && isDistributor && referralCode) { + return { + title: shareConfig.title, + query: query || shareConfig.query, + imageUrl: shareConfig.imageUrl + } + } + + // 默认分享逻辑 + if (!isDistributor || !referralCode) { + return { + title: '心伴AI - 情感陪伴聊天机器人', + imageUrl: '/images/share-cover.jpg' + } + } + + return { + title: `我的推荐码:${referralCode},注册即可享受优惠!`, + query: `referralCode=${referralCode}`, + imageUrl: '/images/share-commission.png' + } + } +}) diff --git a/pages/promote/promote.json b/pages/promote/promote.json new file mode 100644 index 0000000..780ca80 --- /dev/null +++ b/pages/promote/promote.json @@ -0,0 +1,7 @@ +{ + "navigationStyle": "custom", + "enablePullDownRefresh": true, + "usingComponents": { + "app-icon": "/components/icon/icon" + } +} diff --git a/pages/promote/promote.wxml b/pages/promote/promote.wxml new file mode 100644 index 0000000..4103867 --- /dev/null +++ b/pages/promote/promote.wxml @@ -0,0 +1,123 @@ + + + + + + + + + + + 推广中心 + + + + + + + {{commissionStats.shareCount}} + 分享次数 + + + {{commissionStats.registeredCount}} + 注册人数 + + + {{commissionStats.orderCount}} + 下单人数 + + + + + + + + + + + + 已推广用户 + + + 全部 + + + + + + + + 暂无推广用户 + + + + + + + + + 推荐 + {{item.referralCount}} + + + 业绩 + ¥{{item.performance}} + + + + + + + + + + + + + + 生成推广海报 + + + + + + + + + + 复制推广码 ({{referralCode || '...'}}) + + + + + + + + + + + diff --git a/pages/promote/promote.wxss b/pages/promote/promote.wxss new file mode 100644 index 0000000..9b555bf --- /dev/null +++ b/pages/promote/promote.wxss @@ -0,0 +1,338 @@ +/* pages/promote/promote.wxss */ +.page { + min-height: 100vh; + background-color: #F8F9FC; + display: flex; + flex-direction: column; +} + +/* Header Section (Purple Gradient) */ +.header-section { + background: linear-gradient(180deg, #D499D8 0%, #B06AB3 100%); + padding-bottom: 60rpx; /* Space for content overlap if needed, or just padding */ + position: relative; + border-radius: 0 0 40rpx 40rpx; +} + +/* Nav Bar */ +.nav-container { + position: absolute; + top: 0; + left: 0; + width: 100%; + z-index: 100; +} + +.nav-bar { + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.nav-back { + position: absolute; + left: 10rpx; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + padding: 20rpx; + z-index: 10; +} + +.nav-title { + font-size: 34rpx; + font-weight: 700; + color: #FFFFFF; +} + +/* Stats Grid */ +.stats-grid { + display: flex; + justify-content: space-around; + padding: 40rpx 0; + margin-top: 20rpx; +} + +.stats-item { + display: flex; + flex-direction: column; + align-items: center; + color: #FFFFFF; +} + +.stats-value { + font-size: 60rpx; + font-weight: 800; + margin-bottom: 12rpx; +} + +.stats-label { + font-size: 30rpx; + opacity: 0.95; + font-weight: 500; +} + +/* Amount Card */ +.amount-card { + padding: 0 40rpx; + margin-top: 20rpx; + color: #FFFFFF; +} + +.amount-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16rpx; +} + +.amount-label { + font-size: 28rpx; + opacity: 0.9; +} + +.history-btn-wrap { + display: flex; + align-items: center; + gap: 4rpx; + background: rgba(255, 255, 255, 0.2); + padding: 6rpx 16rpx; + border-radius: 20rpx; +} + +.history-text { + font-size: 24rpx; +} + +.amount-value-row { + display: flex; + align-items: center; + justify-content: space-between; +} + +.amount-value { + font-size: 80rpx; + font-weight: 900; + line-height: 1; +} + +.withdraw-btn { + background: #FFFFFF; + color: #B06AB3; + font-size: 28rpx; + font-weight: 700; + padding: 16rpx 40rpx; + border-radius: 40rpx; + box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.1); +} + +/* Main Body */ +.main-body { + padding: 0 32rpx; + margin-top: -30rpx; /* Slight overlap */ +} + +/* Referral Panel */ +.referral-panel { + background: #FFFFFF; + border-radius: 32rpx; + padding: 32rpx; + margin-bottom: 32rpx; + box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.02); +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 32rpx; + padding-top:42rpx; +} + +.header-left { + display: flex; + align-items: center; + gap: 12rpx; +} + +.purple-dot { + width: 10rpx; + height: 38rpx; + background: #B06AB3; + border-radius: 6rpx; +} + +.panel-title { + font-size: 38rpx; + font-weight: 800; + color: #111827; +} + +.referral-count { + font-size: 38rpx; + font-weight: 800; + color: #B06AB3; +} + +.see-all { + font-size: 32rpx; + color: #B06AB3; + font-weight: 700; +} + +.user-list { + display: flex; + flex-direction: column; + gap: 32rpx; +} + +.user-row { + display: flex; + align-items: center; + padding: 32rpx 0; + border-bottom: 2rpx solid #F3F4F6; +} + +.user-row:last-child { + border-bottom: none; +} + +.user-avatar { + width: 110rpx; + height: 110rpx; + border-radius: 50%; + background: #F3F4F6; + margin-right: 24rpx; + flex-shrink: 0; +} + +.user-info { + flex: 1; + min-width: 0; +} + +.user-main { + display: flex; + align-items: center; + gap: 16rpx; + margin-bottom: 8rpx; +} + +.user-name { + font-size: 36rpx; + font-weight: 700; + color: #1F2937; + max-width: 240rpx; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-level { + font-size: 24rpx; + color: #B06AB3; + background: rgba(176, 106, 179, 0.1); + padding: 4rpx 16rpx; + border-radius: 20rpx; + font-weight: 600; +} + +.user-sub { + font-size: 28rpx; + color: #4B5563; +} + +.user-stats { + display: flex; + gap: 24rpx; + text-align: right; +} + +.stat-item { + display: flex; + flex-direction: column; + min-width: 100rpx; +} + +.stat-label { + font-size: 24rpx; + color: #9CA3AF; + margin-bottom: 6rpx; +} + +.stat-value { + font-size: 30rpx; + font-weight: 700; + color: #1F2937; +} + +.empty-state { + width: 100%; + text-align: center; + color: #9CA3AF; + font-size: 26rpx; + padding: 40rpx 0; +} + +/* Menu List */ +.menu-list { + display: flex; + flex-direction: column; + gap: 24rpx; +} + +.menu-item { + background: #FFFFFF; + border-radius: 24rpx; + padding: 40rpx 32rpx; + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: 0 2rpx 10rpx rgba(0,0,0,0.01); +} + +.menu-left { + display: flex; + align-items: center; + gap: 32rpx; +} + +.icon-circle { + width: 88rpx; + height: 88rpx; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.icon-circle.purple { background: rgba(176, 106, 179, 0.1); } +.icon-circle.pink { background: rgba(236, 72, 153, 0.1); } +.icon-circle.orange { background: rgba(245, 158, 11, 0.1); } + +.menu-text { + font-size: 34rpx; + font-weight: 700; + color: #1F2937; +} + +.btn-reset { + background: none; + border: none; + padding: 40rpx 32rpx; /* Match menu-item padding */ + margin: 0; + line-height: normal; + border-radius: 24rpx; /* Match menu-item border-radius */ + display: flex !important; /* Force flex for button */ + flex-direction: row !important; + align-items: center !important; + justify-content: space-between !important; + box-shadow: 0 2rpx 10rpx rgba(0,0,0,0.01); + background-color: #FFFFFF; +} + +.btn-reset::after { + border: none; +} diff --git a/pages/recharge/recharge.js b/pages/recharge/recharge.js new file mode 100644 index 0000000..be45c6b --- /dev/null +++ b/pages/recharge/recharge.js @@ -0,0 +1,151 @@ +const { request } = require('../../utils_new/request'); +const { payProduct } = require('../../utils_new/payment'); +const api = require('../../utils/api'); + +Page({ + data: { + statusBarHeight: 20, + navBarHeight: 44, + totalNavHeight: 64, + products: [], + vipPackages: [], + modal: { + visible: false, + type: 'product', + id: 0, + orderType: '', + title: '', + price: 0, + benefits: [] + } + }, + + onLoad() { + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 20 + const menuButton = wx.getMenuButtonBoundingClientRect() + const navBarHeight = menuButton.height + (menuButton.top - statusBarHeight) * 2 + + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight: statusBarHeight + navBarHeight + }) + }, + + onShow() { + this.loadProducts() + }, + + async loadProducts() { + try { + const res = await api.payment.getPackages() + const body = res.data || {} + const list = Array.isArray(body) ? body : body?.data || [] + + if (!list.length) return + + const raw = list.map((p) => { + const attrs = p.attributes || {} + + const gradientStart = p.gradientStart || attrs.gradient_start || '#60A5FA' + const gradientEnd = p.gradientEnd || attrs.gradient_end || '#2563EB' + const tagColor = p.tagColor || attrs.tag_color || '#1E3A8A' + const tagText = p.tagText || attrs.tag_text || '' + const benefits = p.benefits || attrs.benefits || [] + + const iconMap = { + first: 'gift', + month: 'gift', + yearly: 'gift', + year: 'crown', + svip: 'diamond', + soulmate: 'heart-filled', + guardian: 'diamond', + companion: 'crown', + listener: 'gift' + } + + let vipType = attrs.type || p.vipType || '' + if (vipType === 'monthly') vipType = 'month' + if (vipType === 'yearly') vipType = 'year' + if (vipType === 'lifetime') vipType = 'svip' + + return { + id: Number(p.id), + orderType: 'vip', + vipType, + price: Number(p.price) || 0, + originalPrice: Number(p.originalPrice) || 0, + title: p.title, + subtitle: p.subtitle || attrs.subtitle || (benefits[0] || ''), + tagText: tagText, + gradient: `background: linear-gradient(180deg, ${gradientStart}, ${gradientEnd});`, + tagStyle: `color: ${tagColor}; background: rgba(255,255,255,0.25);`, + icon: iconMap[vipType] || 'crown', + iconGradient: `background: linear-gradient(180deg, ${gradientStart}, ${gradientEnd});`, + priceColor: `color: ${gradientEnd};`, + checkColor: tagColor || gradientEnd, + borderStyle: `border-color: rgba(0,0,0,0.06); background: rgba(255,255,255,0.9);`, + btnGradient: `background: linear-gradient(90deg, ${gradientStart}, ${gradientEnd});`, + benefits, + isRecommend: p.isRecommend || attrs.is_recommended || false + } + }) + + this.setData({ + products: raw, + vipPackages: raw + }) + } catch (err) { + console.error('Failed to load products:', err) + } + }, + + onBack() { + wx.navigateBack({ delta: 1 }) + }, + + openProductPay(e) { + const id = Number(e.currentTarget.dataset.id) + const product = this.data.products.find(x => Number(x.id) === id) + if (!product) return + + const pkg = this.data.vipPackages.find(x => Number(x.id) === id) + const benefits = pkg?.benefits || [] + + this.setData({ + modal: { + visible: true, + type: 'product', + id: Number(product.id), + orderType: product.orderType, + title: product.title, + price: product.price, + benefits + } + }) + }, + + closeModal() { + this.setData({ 'modal.visible': false }) + }, + + async confirmAction() { + const modal = this.data.modal + + wx.showLoading({ title: '发起支付...' }) + try { + await payProduct({ + productId: modal.id, + orderType: modal.orderType + }) + wx.hideLoading() + wx.showToast({ title: '购买成功', icon: 'success' }) + this.closeModal() + } catch (err) { + wx.hideLoading() + wx.showToast({ title: err.message || '支付失败', icon: 'none' }) + } + } +}) diff --git a/pages/recharge/recharge.json b/pages/recharge/recharge.json new file mode 100644 index 0000000..3153ca5 --- /dev/null +++ b/pages/recharge/recharge.json @@ -0,0 +1,5 @@ +{ + "usingComponents": { + "app-icon": "/components/icon/icon" + } +} diff --git a/pages/recharge/recharge.wxml b/pages/recharge/recharge.wxml new file mode 100644 index 0000000..c2980d1 --- /dev/null +++ b/pages/recharge/recharge.wxml @@ -0,0 +1,101 @@ + + + + + + 返回 + + 购买中心 + + + + + + + + {{item.tagText}} + + + ¥ + {{item.price}} + ¥{{item.originalPrice}} + + {{item.title}} + {{item.subtitle}} + + 立即购买 + + + + + + + 套餐内容 + + + + + 推荐 + + + + + + {{item.title}} + {{item.subtitle}} + + + ¥{{item.price}} + ¥{{item.originalPrice}} + + + + + + + {{benefit}} + + + + + + + + + + + + + + + + 确认订单 + 请确认您的购买信息 + + + + + + + + {{modal.title}} + 购买开通 + + + ¥{{modal.price}} + + + + + + + {{benefit}} + + + + + + + diff --git a/pages/recharge/recharge.wxss b/pages/recharge/recharge.wxss new file mode 100644 index 0000000..ec36a0d --- /dev/null +++ b/pages/recharge/recharge.wxss @@ -0,0 +1,525 @@ +.page { + min-height: 100vh; + background: #E8C3D4; + display: flex; + flex-direction: column; +} + +/* 顶部导航栏已移除,改用全局 unified-header */ + +/* Tabs */ +.tabs { + background: #ffffff; + display: flex; + justify-content: center; + gap: 80rpx; + padding-bottom: 20rpx; + border-bottom-left-radius: 32rpx; + border-bottom-right-radius: 32rpx; + position: relative; + z-index: 10; +} + +.tab-item { + position: relative; + font-size: 32rpx; + font-weight: 700; + color: #9CA3AF; + padding: 10rpx 0; +} + +.tab-item.active { + color: #111827; + font-weight: 900; + font-size: 34rpx; +} + +.tab-indicator { + position: absolute; + bottom: -10rpx; + left: 50%; + transform: translateX(-50%); + width: 40rpx; + height: 6rpx; + background: #B06AB3; + border-radius: 999rpx; +} + +/* Content */ +.content { + flex: 1; +} + +.tab-content { + padding-bottom: 80rpx; +} + +/* Special Offers Grid */ +.special-offers { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 24rpx; + margin-bottom: 40rpx; + padding: 0 24rpx; +} + +.offer-card { + width: 100%; + height: 412rpx; + border-radius: 32rpx; + padding: 32rpx; + box-sizing: border-box; + position: relative; + color: #ffffff; + display: flex; + flex-direction: column; + justify-content: space-between; + box-shadow: 0 10rpx 15rpx -3rpx rgba(0,0,0,0.1); + overflow: hidden; +} + +.offer-tag { + position: absolute; + top: 0; + right: 0; + padding: 10rpx 24rpx; + border-bottom-left-radius: 28rpx; + font-size: 24rpx; + font-weight: 900; + letter-spacing: 0.5px; +} + +.offer-body { + position: relative; + z-index: 2; + margin-top: 10rpx; +} + +.offer-price-row { + display: flex; + align-items: baseline; + margin-bottom: 8rpx; +} + +.offer-symbol { + font-size: 32rpx; + font-weight: 700; + margin-right: 4rpx; +} + +.offer-price { + font-size: 72rpx; + font-weight: 700; + line-height: 1; + letter-spacing: -2rpx; +} + +.offer-oprice { + font-size: 28rpx; + text-decoration: line-through; + opacity: 0.9; + margin-left: 12rpx; + font-weight: 700; +} + +.offer-title { + display: block; + font-size: 40rpx; + font-weight: 900; + margin-top: 12rpx; + line-height: 1.4; + letter-spacing: -1rpx; +} + +.offer-sub { + display: block; + font-size: 28rpx; + font-weight: 700; + opacity: 1; + margin-top: 4rpx; + letter-spacing: 0.5px; +} + +.offer-btn { + background: rgba(0,0,0,0.15); + text-align: center; + padding: 20rpx 0; + border-radius: 999rpx; + font-size: 32rpx; + font-weight: 900; + box-shadow: 0 2rpx 4rpx rgba(0,0,0,0.1); + position: relative; + z-index: 2; + letter-spacing: 1.5px; +} + +/* Decorative background icon simulation */ +.offer-card::after { + content: ''; + position: absolute; + bottom: -20rpx; + right: -20rpx; + width: 180rpx; + height: 180rpx; + background-repeat: no-repeat; + background-size: contain; + opacity: 0.12; + transform: rotate(-15deg); + pointer-events: none; +} + +.offer-card.first::after { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 12 20 22 4 22 4 12'/%3E%3Crect x='2' y='7' width='20' height='5'/%3E%3Cline x1='12' y1='22' x2='12' y2='7'/%3E%3Cpath d='M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z'/%3E%3Cpath d='M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z'/%3E%3C/svg%3E"); +} + +.offer-card.month::after { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolygon points='12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2'/%3E%3C/svg%3E"); +} + +.offer-card.year::after { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 4l3 12h14l3-12-6 7-4-7-4 7-6-7z'/%3E%3Cpath d='M19 16l1 3H4l1-3'/%3E%3C/svg%3E"); +} + +.offer-card.svip::after { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 3h12l4 6-10 12L2 9z'/%3E%3Cpath d='M11 3l-4 6 5 11 5-11-4-6'/%3E%3Cpath d='M2 9h20'/%3E%3C/svg%3E"); +} + +.offer-card.soulmate::after { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z'/%3E%3C/svg%3E"); +} + +/* Member Center */ +.member-center { + background: #ffffff; + border-radius: 40rpx; + padding: 32rpx; + border: 2rpx solid #F3D1EA; +} + +.section-head { + display: flex; + align-items: center; + gap: 16rpx; + margin-bottom: 32rpx; +} + +.section-indicator { + width: 8rpx; + height: 32rpx; + background: #B06AB3; + border-radius: 999rpx; +} + +.section-indicator.yellow { + background: #FFD700; +} + +.section-title { + font-size: 36rpx; + font-weight: 900; + color: #111827; +} + +.vip-only-tag { + margin-left: 16rpx; + background: #FFF9E6; + color: #B8860B; + font-size: 20rpx; + font-weight: 800; + padding: 4rpx 12rpx; + border-radius: 999rpx; + border: 2rpx solid rgba(255, 215, 0, 0.3); +} + +.vip-list { + display: flex; + flex-direction: column; + gap: 24rpx; +} + +.vip-item { + background: #FDF4F9; + border-radius: 32rpx; + padding: 32rpx; + border: 4rpx solid transparent; + position: relative; + overflow: hidden; +} + +.vip-tag-corner { + position: absolute; + top: 0; + right: 0; + background: #FFD700; + color: #7C2D12; + font-size: 20rpx; + font-weight: 900; + padding: 8rpx 24rpx; + border-bottom-left-radius: 24rpx; +} + +.vip-main { + display: flex; + align-items: center; + gap: 24rpx; + margin-bottom: 24rpx; +} + +.vip-icon-box { + width: 96rpx; + height: 96rpx; + border-radius: 24rpx; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 8rpx 16rpx rgba(0,0,0,0.1); +} + +.vip-info { + flex: 1; +} + +.vip-title { + display: block; + font-size: 34rpx; + font-weight: 900; + color: #111827; +} + +.vip-desc { + display: block; + font-size: 24rpx; + font-weight: 700; + color: #6B7280; + margin-top: 4rpx; +} + +.vip-price-col { + text-align: right; +} + +.vip-price { + display: block; + font-size: 44rpx; + font-weight: 900; +} + +.vip-oprice { + display: block; + font-size: 24rpx; + color: #9CA3AF; + text-decoration: line-through; +} + +.vip-benefits { + margin-bottom: 24rpx; + padding-left: 8rpx; + display: flex; + flex-direction: column; + gap: 12rpx; +} + +.benefit-row { + display: flex; + align-items: center; + gap: 12rpx; +} + +.benefit-text { + font-size: 26rpx; + color: #374151; + font-weight: 600; +} + +.vip-buy-btn { + width: 100%; + padding: 24rpx 0; + border-radius: 24rpx; + color: #ffffff; + font-size: 30rpx; + font-weight: 900; + box-shadow: 0 8rpx 20rpx rgba(0,0,0,0.1); +} + +/* Shop Section */ +.shop-section { + /* same as member center logic */ +} + +.shop-grid { + display: flex; + flex-wrap: wrap; + gap: 24rpx; +} + +.shop-item { + width: calc(33.33% - 16rpx); + background: #ffffff; + border-radius: 24rpx; + overflow: hidden; + box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.05); +} + +.shop-img-box { + height: 180rpx; + background: #F9FAFB; + position: relative; +} + +.shop-img { + width: 100%; + height: 100%; +} + +.exchange-badge { + position: absolute; + top: 8rpx; + right: 8rpx; + background: rgba(0,0,0,0.4); + backdrop-filter: blur(8rpx); + color: #ffffff; + font-size: 18rpx; + padding: 4rpx 12rpx; + border-radius: 999rpx; + font-weight: 700; +} + +.shop-info { + padding: 16rpx; +} + +.shop-name { + display: block; + font-size: 24rpx; + font-weight: 800; + color: #111827; + margin-bottom: 8rpx; +} + +.shop-cost-row { + display: flex; + align-items: center; + gap: 6rpx; +} + +.shop-cost { + font-size: 24rpx; + font-weight: 900; + color: #B06AB3; +} + +/* Modal */ +.modal { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; +} + +.mask { + position: absolute; + inset: 0; + background: rgba(0,0,0,0.6); + backdrop-filter: blur(8rpx); +} + +.sheet { + position: relative; + width: 80%; + max-width: 600rpx; + background: #ffffff; + border-radius: 48rpx; + padding: 40rpx; + animation: popUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +@keyframes popUp { + from { transform: scale(0.9); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} + +.sheet-head { + text-align: center; + margin-bottom: 40rpx; +} + +.sheet-title { + display: block; + font-size: 36rpx; + font-weight: 900; + color: #111827; +} + +.sheet-sub { + display: block; + font-size: 24rpx; + color: #6B7280; + margin-top: 8rpx; +} + +.sheet-card { + background: #F9FAFB; + border-radius: 32rpx; + padding: 32rpx; + display: flex; + align-items: center; + gap: 24rpx; + margin-bottom: 40rpx; +} + +.sheet-icon-box { + width: 96rpx; + height: 96rpx; + background: #ffffff; + border-radius: 24rpx; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.05); +} + +.sheet-info { + flex: 1; +} + +.sheet-name { + display: block; + font-size: 32rpx; + font-weight: 900; + color: #111827; +} + +.sheet-desc { + font-size: 24rpx; + color: #6B7280; + margin-top: 4rpx; +} + +.sheet-price-box { + text-align: right; +} + +.sheet-price-val { + font-size: 40rpx; + font-weight: 900; + color: #B06AB3; +} + +.sheet-price-unit { + font-size: 20rpx; + color: #B06AB3; + margin-left: 4rpx; +} + +.sheet-btn { + width: 100%; + padding: 28rpx 0; + background: #B06AB3; + color: #ffffff; + border-radius: 24rpx; + font-size: 32rpx; + font-weight: 900; + box-shadow: 0 12rpx 24rpx rgba(176, 106, 179, 0.3); +} diff --git a/pages/referrals/orders/orders.js b/pages/referrals/orders/orders.js new file mode 100644 index 0000000..71cb5f0 --- /dev/null +++ b/pages/referrals/orders/orders.js @@ -0,0 +1,107 @@ +const api = require('../../../utils/api') +const util = require('../../../utils/util') + +Page({ + data: { + userInfo: null, + list: [], + page: 1, + pageSize: 20, + hasMore: true, + loading: false + }, + + onLoad(options) { + if (options.userInfo) { + try { + const userInfo = JSON.parse(decodeURIComponent(options.userInfo)) + this.setData({ userInfo }) + wx.setNavigationBarTitle({ + title: `${userInfo.name}的订单` + }) + } catch (e) { + console.error('解析用户信息失败', e) + } + } + + this.loadOrders() + }, + + onPullDownRefresh() { + this.setData({ + page: 1, + hasMore: true, + list: [] + }, () => { + this.loadOrders().then(() => { + wx.stopPullDownRefresh() + }) + }) + }, + + onReachBottom() { + if (this.data.loading || !this.data.hasMore) return + this.setData({ + page: this.data.page + 1 + }, () => { + this.loadOrders() + }) + }, + + async loadOrders() { + if (this.data.loading) return + + this.setData({ loading: true }) + + try { + const res = await api.commission.getRecords({ + page: this.data.page, + pageSize: this.data.pageSize, + fromUserId: this.data.userInfo?.userId + }) + + if (res.success) { + const newList = res.data.list.map(item => ({ + ...item, + orderTypeText: this.getOrderTypeText(item.orderType), + statusText: this.getStatusText(item.status), + statusClass: item.status, + createdAtFormatted: util.formatTime(new Date(item.createdAt)) + })) + + this.setData({ + list: this.data.page === 1 ? newList : [...this.data.list, ...newList], + hasMore: newList.length === this.data.pageSize + }) + } + } catch (err) { + console.error('加载订单失败', err) + wx.showToast({ + title: '加载失败', + icon: 'none' + }) + } finally { + this.setData({ loading: false }) + } + }, + + getOrderTypeText(type) { + const map = { + 'recharge': '充值', + 'vip': 'VIP会员', + 'identity_card': '身份卡', + 'agent_purchase': '智能体购买', + 'companion_chat': '陪聊' + } + return map[type] || type + }, + + getStatusText(status) { + const map = { + 'pending': '待结算', + 'settled': '已结算', + 'cancelled': '已取消' + } + return map[status] || status + } +}) diff --git a/pages/referrals/orders/orders.json b/pages/referrals/orders/orders.json new file mode 100644 index 0000000..4e9759b --- /dev/null +++ b/pages/referrals/orders/orders.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "用户订单", + "enablePullDownRefresh": true +} \ No newline at end of file diff --git a/pages/referrals/orders/orders.wxml b/pages/referrals/orders/orders.wxml new file mode 100644 index 0000000..db1a776 --- /dev/null +++ b/pages/referrals/orders/orders.wxml @@ -0,0 +1,61 @@ + + + + + + {{userInfo.name}} + 累计贡献:¥{{userInfo.contribution}} + + + + + + + + + {{item.orderTypeText}} + {{item.statusText}} + + + + 订单金额 + ¥{{item.orderAmount}} + + + 获得佣金 + +¥{{item.commissionAmount}} + + + 佣金比例 + {{item.commissionRate}}% + + + 时间 + {{item.createdAtFormatted}} + + + + + + + + + 该用户暂无贡献订单 + + + + + + 加载中... + + + + 没有更多了 + + + diff --git a/pages/referrals/orders/orders.wxss b/pages/referrals/orders/orders.wxss new file mode 100644 index 0000000..db683c0 --- /dev/null +++ b/pages/referrals/orders/orders.wxss @@ -0,0 +1,154 @@ +.container { + min-height: 100vh; + background-color: #f7f7f7; + display: flex; + flex-direction: column; +} + +.header { + background-color: #fff; + padding: 30rpx; + display: flex; + align-items: center; + margin-bottom: 20rpx; +} + +.avatar { + width: 100rpx; + height: 100rpx; + border-radius: 50%; + margin-right: 20rpx; + background-color: #eee; +} + +.info { + flex: 1; +} + +.name { + font-size: 32rpx; + font-weight: bold; + color: #333; + margin-bottom: 8rpx; +} + +.contribution { + font-size: 26rpx; + color: #666; +} + +.list-scroll { + flex: 1; + height: 0; +} + +.list-container { + padding: 0 20rpx; +} + +.list-item { + background-color: #fff; + border-radius: 12rpx; + margin-bottom: 20rpx; + padding: 30rpx; + box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05); +} + +.item-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 20rpx; + border-bottom: 1rpx solid #eee; + margin-bottom: 20rpx; +} + +.order-type { + font-size: 30rpx; + font-weight: bold; + color: #333; +} + +.status { + font-size: 26rpx; + padding: 4rpx 12rpx; + border-radius: 4rpx; +} + +.status.pending { + color: #faad14; + background-color: #fffbe6; + border: 1rpx solid #ffe58f; +} + +.status.settled { + color: #52c41a; + background-color: #f6ffed; + border: 1rpx solid #b7eb8f; +} + +.status.cancelled { + color: #999; + background-color: #f5f5f5; + border: 1rpx solid #d9d9d9; +} + +.item-content { + font-size: 28rpx; + color: #666; +} + +.row { + display: flex; + justify-content: space-between; + margin-bottom: 12rpx; +} + +.row:last-child { + margin-bottom: 0; +} + +.label { + color: #999; +} + +.value { + color: #333; +} + +.value.highlight { + color: #ff4d4f; + font-weight: bold; +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding-top: 200rpx; + color: #999; + font-size: 28rpx; +} + +.empty-icon { + width: 200rpx; + height: 200rpx; + margin-bottom: 30rpx; +} + +.loading-state { + display: flex; + flex-direction: column; + align-items: center; + padding: 30rpx; + color: #999; + font-size: 24rpx; +} + +.no-more { + text-align: center; + padding: 30rpx; + color: #ccc; + font-size: 24rpx; +} diff --git a/pages/referrals/referrals.js b/pages/referrals/referrals.js new file mode 100644 index 0000000..1adbbb1 --- /dev/null +++ b/pages/referrals/referrals.js @@ -0,0 +1,265 @@ +// pages/referrals/referrals.js +const api = require('../../utils/api') +const util = require('../../utils/util') + +Page({ + data: { + // 导航栏高度 + statusBarHeight: 44, + navBarHeight: 44, + totalNavHeight: 88, + + // 统计数据 + stats: { + totalReferrals: 0, + totalContribution: 0 + }, + + // 推荐用户列表 + list: [], + defaultAvatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=500&auto=format&fit=crop&q=60', + + // 分页 + page: 1, + pageSize: 20, + total: 0, + hasMore: true, + + // 状态 + loading: false, + isEmpty: false, + + // 搜索与筛选 + searchKeyword: '', + levelFilter: '', + levelRange: [ + { label: '全部等级', value: '' }, + { label: '心伴会员', value: 'soulmate' }, + { label: '守护会员', value: 'guardian' }, + { label: '陪伴会员', value: 'companion' }, + { label: '倾听会员', value: 'listener' }, + { label: '城市合伙人', value: 'partner' } + ], + levelIndex: 0 + }, + + onLoad(options) { + // 计算导航栏高度 + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + const menuButton = wx.getMenuButtonBoundingClientRect() + const navBarHeight = menuButton.height + (menuButton.top - statusBarHeight) * 2 + const totalNavHeight = statusBarHeight + navBarHeight + + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight + }) + + // 加载数据 + this.loadReferrals() + }, + + /** + * 加载推荐用户列表 + */ + async loadReferrals(page = 1) { + if (this.data.loading) return + + this.setData({ loading: true }) + + try { + const res = await api.commission.getReferrals({ + page, + pageSize: this.data.pageSize, + keyword: this.data.searchKeyword, + level: this.data.levelFilter + }) + + console.log('推荐用户列表 API 响应:', res) + + // 处理不同的响应格式 + let listData = [] + let totalCount = 0 + + if (res.success && res.data) { + // 格式1: { success: true, data: { list: [...], total: 0 } } + listData = res.data.list || res.data || [] + totalCount = res.data.total || 0 + } else if (res.list) { + // 格式2: { list: [...], total: 0 } + listData = res.list || [] + totalCount = res.total || 0 + } else if (Array.isArray(res)) { + // 格式3: [...] + listData = res + totalCount = res.length + } else { + // 未知格式,设为空数组 + console.warn('未知的 API 响应格式:', res) + listData = [] + totalCount = 0 + } + + const roleMap = { + 'soulmate': '心伴会员', + 'guardian': '守护会员', + 'companion': '陪伴会员', + 'listener': '倾听会员', + 'partner': '城市合伙人' + }; + + // 预处理列表数据,增加等级显示 + const listDataProcessed = listData.map(item => { + const roleCode = item.distributorRole || item.role || ''; + + // 处理头像 + let avatar = item.userAvatar || item.avatar || item.avatarUrl; + if (avatar) { + avatar = util.getFullImageUrl(avatar); + } + + return { + ...item, + userAvatar: avatar || this.data.defaultAvatar, + levelText: roleMap[roleCode] || (item.isDistributor ? '分销会员' : '普通用户') + }; + }); + + const list = page === 1 ? listDataProcessed : [...this.data.list, ...listDataProcessed]; + const hasMore = listData.length === this.data.pageSize + const isEmpty = list.length === 0 + + // 计算统计数据 + const stats = { + totalReferrals: totalCount, + totalContribution: this.calculateTotalContribution(list) + } + + this.setData({ + stats, + list, + page, + total: totalCount, + hasMore, + isEmpty, + loading: false + }) + } catch (error) { + console.error('加载推荐用户列表失败:', error) + this.setData({ loading: false }) + wx.showToast({ + title: error.message || '加载失败', + icon: 'none' + }) + } + }, + + /** + * 计算累计贡献金额 + */ + calculateTotalContribution(list) { + return list.reduce((sum, item) => sum + (item.totalContribution || 0), 0) + }, + + /** + * 格式化金额 + */ + formatMoney(amount) { + return util.formatMoney(amount) + }, + + /** + * 格式化时间 + */ + formatTime(timestamp) { + return util.formatDate(timestamp) + }, + + /** + * 下拉刷新 + */ + onPullDownRefresh() { + this.loadReferrals(1).then(() => { + wx.stopPullDownRefresh() + }) + }, + + /** + * 上拉加载更多 + */ + onReachBottom() { + if (!this.data.hasMore || this.data.loading) return + this.loadReferrals(this.data.page + 1) + }, + + /** + * 返回上一页 + */ + onBack() { + wx.navigateBack() + }, + + /** + * 重新加载 + */ + onRetry() { + this.loadReferrals(1) + }, + + /** + * 搜索输入 + */ + onSearchInput(e) { + this.setData({ + searchKeyword: e.detail.value + }) + }, + + /** + * 确认搜索 + */ + onSearch() { + this.loadReferrals(1) + }, + + /** + * 清除搜索 + */ + onClearSearch() { + this.setData({ + searchKeyword: '' + }) + this.loadReferrals(1) + }, + + /** + * 等级筛选 + */ + onLevelChange(e) { + const index = e.detail.value + const level = this.data.levelRange[index].value + this.setData({ + levelIndex: index, + levelFilter: level + }) + this.loadReferrals(1) + }, + + /** + * 查看用户订单 + */ + viewOrders(e) { + const item = e.currentTarget.dataset.item + const userInfo = encodeURIComponent(JSON.stringify({ + userId: item.userId, + name: item.userName, + avatar: item.userAvatar, + contribution: item.totalContribution + })) + wx.navigateTo({ + url: `/pages/referrals/orders/orders?userInfo=${userInfo}` + }) + } +}) diff --git a/pages/referrals/referrals.json b/pages/referrals/referrals.json new file mode 100644 index 0000000..a6d230f --- /dev/null +++ b/pages/referrals/referrals.json @@ -0,0 +1,6 @@ +{ + "navigationStyle": "custom", + "enablePullDownRefresh": true, + "backgroundTextStyle": "dark", + "backgroundColor": "#F2EDFF" +} diff --git a/pages/referrals/referrals.wxml b/pages/referrals/referrals.wxml new file mode 100644 index 0000000..ed0b829 --- /dev/null +++ b/pages/referrals/referrals.wxml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + 推荐用户列表 + + + + + + + + + + + + + {{levelRange[levelIndex].label}} + + + + + + + + + + 总推荐人数 + {{stats.totalReferrals}}人 + + + + 累计贡献 + ¥{{formatMoney(stats.totalContribution)}} + + + + + + + + + + + + + + + 上拉加载更多 + + + + + 加载中... + + + + + 没有更多了 + + + + + + + 暂无推荐用户 + 分享您的推荐码,邀请好友注册 + + + + + + 加载中... + + + diff --git a/pages/referrals/referrals.wxss b/pages/referrals/referrals.wxss new file mode 100644 index 0000000..2fa88c3 --- /dev/null +++ b/pages/referrals/referrals.wxss @@ -0,0 +1,306 @@ +/* pages/referrals/referrals.wxss */ +.container { + min-height: 100vh; + background: #F2EDFF; +} + +/* 固定导航栏 */ +.nav-container { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 999; + background: rgba(242, 237, 255, 0.6); + backdrop-filter: blur(10px); +} + +.status-bar { + background: transparent; +} + +.nav-bar { + display: flex; + align-items: center; + justify-content: center; + padding: 0 32rpx; + background: transparent; + position: relative; +} + +.nav-back { + position: absolute; + left: 32rpx; + top: 50%; + transform: translateY(-50%); + width: 80rpx; + height: 80rpx; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +.back-icon { + width: 48rpx; + height: 48rpx; +} + +.nav-title { + font-size: 36rpx; + font-weight: 700; + color: #1F2937; + line-height: 1; +} + +/* 内容滚动区域 */ +.content-scroll { + height: 100vh; + box-sizing: border-box; +} + +/* 统计卡片 */ +.stats-card { + background: linear-gradient(135deg, #B06AB3 0%, #9B4D9E 100%); + border-radius: 24rpx; + padding: 48rpx 32rpx; + margin: 24rpx; + display: flex; + align-items: center; + justify-content: space-around; + box-shadow: 0 8rpx 24rpx rgba(176, 106, 179, 0.3); +} + +.stats-item { + flex: 1; + text-align: center; +} + +.stats-label { + font-size: 28rpx; + color: rgba(255, 255, 255, 0.8); + margin-bottom: 16rpx; +} + +.stats-value { + font-size: 48rpx; + font-weight: 700; + color: #FFFFFF; +} + +.stats-divider { + width: 2rpx; + height: 80rpx; + background: rgba(255, 255, 255, 0.3); +} + +/* 列表容器 */ +.list-container { + padding: 0 24rpx 24rpx; +} + +/* 列表项 */ +.list-item { + background: #FFFFFF; + border-radius: 16rpx; + padding: 24rpx; + margin-bottom: 16rpx; + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05); +} + +.user-info { + display: flex; + align-items: center; + flex: 1; +} + +.user-avatar { + width: 96rpx; + height: 96rpx; + border-radius: 48rpx; + margin-right: 24rpx; + background: #F5F5F5; +} + +.user-details { + flex: 1; +} + +.user-name-row { + display: flex; + align-items: center; + gap: 12rpx; + margin-bottom: 8rpx; +} + +.user-name { + font-size: 36rpx; + font-weight: 600; + color: #1F2937; +} + +/* 筛选栏 */ +.filter-bar { + position: fixed; + left: 0; + right: 0; + height: 100rpx; + background: #FFFFFF; + display: flex; + align-items: center; + padding: 0 24rpx; + z-index: 98; + box-shadow: 0 2rpx 10rpx rgba(0,0,0,0.05); +} + +.search-box { + flex: 1; + height: 64rpx; + background: #F3F4F6; + border-radius: 32rpx; + display: flex; + align-items: center; + padding: 0 24rpx; + margin-right: 20rpx; +} + +.search-box input { + flex: 1; + font-size: 26rpx; + margin-left: 12rpx; + color: #1F2937; +} + +.level-picker { + display: flex; + align-items: center; + gap: 4rpx; + background: #F3F4F6; + padding: 0 20rpx; + height: 64rpx; + border-radius: 32rpx; +} + +.level-picker text { + font-size: 26rpx; + color: #4B5563; + max-width: 160rpx; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-time { + font-size: 28rpx; + color: #9CA3AF; +} + +/* 列表项详情扩展 */ +.user-meta { + font-size: 26rpx; + color: #6B7280; + margin-top: 8rpx; + display: flex; + align-items: center; +} + +.user-level { + font-size: 24rpx; + color: #B06AB3; + background: rgba(176, 106, 179, 0.1); + padding: 4rpx 16rpx; + border-radius: 20rpx; + font-weight: 600; +} + +.user-contribution { + display: none; /* 已合并到详情中 */ +} + + +.arrow-right { + margin-left: 16rpx; + display: flex; + align-items: center; + flex-shrink: 0; +} + +.contribution-label { + font-size: 24rpx; + color: #9CA3AF; + margin-bottom: 8rpx; +} + +.contribution-value { + font-size: 32rpx; + font-weight: 700; + color: #B06AB3; +} + +/* 加载更多 */ +.load-more { + text-align: center; + padding: 32rpx 0; + font-size: 28rpx; + color: #9CA3AF; +} + +/* 空状态 */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 120rpx 48rpx; +} + +.empty-icon { + width: 240rpx; + height: 240rpx; + margin-bottom: 32rpx; + opacity: 0.6; +} + +.empty-text { + font-size: 32rpx; + color: #6B7280; + margin-bottom: 16rpx; +} + +.empty-tip { + font-size: 28rpx; + color: #9CA3AF; +} + +/* 加载状态 */ +.loading-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 120rpx 48rpx; +} + +.loading-spinner { + width: 80rpx; + height: 80rpx; + border: 6rpx solid #E5E7EB; + border-top-color: #B06AB3; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.loading-text { + font-size: 28rpx; + color: #9CA3AF; + margin-top: 24rpx; +} diff --git a/pages/service-provider-detail/service-provider-detail.js b/pages/service-provider-detail/service-provider-detail.js new file mode 100644 index 0000000..85446b3 --- /dev/null +++ b/pages/service-provider-detail/service-provider-detail.js @@ -0,0 +1,318 @@ +// 服务人员详情页 +const api = require('../../utils/api') +const app = getApp() + +Page({ + data: { + statusBarHeight: 44, + navBarHeight: 44, + totalNavHeight: 88, + provider: null, + reviews: [], + showBookingModal: false, + showAllReviewsModal: false, + // 预约表单数据 + selectedType: '', + serviceTypes: [], + bookingTime: '', + bookingAddress: '', + remark: '', + loading: true + }, + + onLoad(options) { + // 计算导航栏高度 + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + const menuButton = wx.getMenuButtonBoundingClientRect() + const navBarHeight = menuButton.height + (menuButton.top - statusBarHeight) * 2 + const totalNavHeight = statusBarHeight + navBarHeight + + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight + }) + + // 加载服务人员数据 + if (options.id) { + this.loadProviderDetail(options.id) + } else { + // 使用模拟数据 + this.loadMockData() + } + }, + + /** + * 加载服务人员详情 + */ + async loadProviderDetail(id) { + this.setData({ loading: true }) + + try { + const res = await new Promise((resolve, reject) => { + wx.request({ + url: `${config.API_BASE_URL}/service/providers/${id}`, + method: 'GET', + success: (res) => resolve(res), + fail: (err) => reject(err) + }) + }) + + if (res.statusCode === 200 && res.data.success) { + const provider = res.data.data + const baseUrl = 'https://ai-c.maimanji.com' + + // 处理头像URL + if (provider.avatar && provider.avatar.startsWith('/')) { + provider.avatar = baseUrl + provider.avatar + } + + // 处理服务类型 + let serviceTypes = provider.serviceTypes || [] + if (serviceTypes.length === 0) { + // 默认服务类型 + serviceTypes = [ + { id: 'basic', name: '基础服务', price: provider.price, unit: provider.unit || '小时' } + ] + } + + // 模拟评价数据(暂时保留,因为后端未返回) + const reviews = [ + { + id: 1, + userName: '138****6172', + avatar: '', + rating: 5, + content: '非常专业,服务态度很好。工作认真负责,每次都能把家里打扫得干干净净。非常满意!', + tags: ['专业', '认真', '细心'], + date: '2024-01-15' + } + ] + + this.setData({ + provider, + reviews, + serviceTypes, + selectedType: serviceTypes[0].id, + loading: false + }) + } else { + wx.showToast({ title: '加载失败', icon: 'none' }) + setTimeout(() => wx.navigateBack(), 2000) + } + } catch (err) { + console.error('加载服务人员详情失败', err) + wx.showToast({ title: '网络错误', icon: 'none' }) + setTimeout(() => wx.navigateBack(), 2000) + } + }, + + /** + * 返回 + */ + onBack() { + wx.navigateBack() + }, + + /** + * 分享 + */ + onShare() { + wx.showShareMenu({ + withShareTicket: true, + menus: ['shareAppMessage', 'shareTimeline'] + }) + }, + + /** + * 咨询 + */ + onConsult() { + // 检查登录 + if (app.checkNeedLogin && app.checkNeedLogin()) return + + wx.showToast({ title: '咨询功能开发中', icon: 'none' }) + }, + + /** + * 立即预约 + */ + onBook() { + // 检查登录 + if (app.checkNeedLogin && app.checkNeedLogin()) return + + this.setData({ showBookingModal: true }) + }, + + /** + * 关闭预约弹窗 + */ + closeBookingModal() { + this.setData({ showBookingModal: false }) + }, + + /** + * 选择服务类型 + */ + selectServiceType(e) { + const id = e.currentTarget.dataset.id + this.setData({ selectedType: id }) + }, + + /** + * 选择预约时间 + */ + onPickTime() { + const now = new Date() + const currentDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` + const currentTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}` + + wx.showModal({ + title: '选择时间', + content: '请使用日期时间选择器', + showCancel: false, + success: () => { + // 简化版:直接设置当前时间 + this.setData({ + bookingTime: `${currentDate} ${currentTime}` + }) + } + }) + }, + + /** + * 选择服务地址 + */ + onPickAddress() { + wx.chooseLocation({ + success: (res) => { + this.setData({ + bookingAddress: res.address + res.name + }) + }, + fail: (err) => { + if (err.errMsg.includes('auth deny')) { + wx.showModal({ + title: '需要位置权限', + content: '请在设置中开启位置权限', + confirmText: '去设置', + success: (res) => { + if (res.confirm) { + wx.openSetting() + } + } + }) + } + } + }) + }, + + /** + * 备注输入 + */ + onRemarkInput(e) { + this.setData({ remark: e.detail.value }) + }, + + /** + * 计算价格 + */ + calculatePrice() { + const { serviceTypes, selectedType } = this.data + const type = serviceTypes.find(t => t.id === selectedType) + return type ? type.price : 0 + }, + + /** + * 确认预约 + */ + async confirmBooking() { + const { selectedType, bookingTime, bookingAddress, remark, provider } = this.data + + // 验证表单 + if (!bookingTime) { + wx.showToast({ title: '请选择预约时间', icon: 'none' }) + return + } + + if (!bookingAddress) { + wx.showToast({ title: '请选择服务地址', icon: 'none' }) + return + } + + wx.showLoading({ title: '提交中...' }) + + try { + // TODO: 调用实际API + // const res = await api.service.createBooking({ + // providerId: provider.id, + // serviceType: selectedType, + // bookingTime, + // address: bookingAddress, + // remark + // }) + + // 模拟API调用 + await new Promise(resolve => setTimeout(resolve, 1000)) + + wx.hideLoading() + wx.showToast({ title: '预约成功', icon: 'success' }) + + this.setData({ showBookingModal: false }) + + // 跳转到订单详情 + setTimeout(() => { + wx.navigateTo({ + url: '/pages/order-detail/order-detail?id=mock123' + }) + }, 1500) + } catch (err) { + wx.hideLoading() + console.error('预约失败', err) + wx.showToast({ title: '预约失败', icon: 'none' }) + } + }, + + /** + * 查看全部评价 + */ + onViewAllReviews() { + this.setData({ showAllReviewsModal: true }) + }, + + /** + * 关闭全部评价弹窗 + */ + closeAllReviewsModal() { + this.setData({ showAllReviewsModal: false }) + }, + + /** + * 分享给好友 + */ + onShareAppMessage() { + const { provider } = this.data + const referralCode = wx.getStorageSync('referralCode') || '' + const referralCodeParam = referralCode ? `&referralCode=${referralCode}` : '' + return { + title: `推荐服务人员:${provider.name}`, + path: `/pages/service-provider-detail/service-provider-detail?id=${provider.id}${referralCodeParam}`, + imageUrl: provider.avatar + } + }, + + /** + * 分享到朋友圈 + */ + onShareTimeline() { + const { provider } = this.data + const referralCode = wx.getStorageSync('referralCode') || '' + const query = `id=${provider.id}${referralCode ? `&referralCode=${referralCode}` : ''}` + return { + title: `推荐服务人员:${provider.name}`, + query: query, + imageUrl: provider.avatar + } + } +}) diff --git a/pages/service-provider-detail/service-provider-detail.json b/pages/service-provider-detail/service-provider-detail.json new file mode 100644 index 0000000..b16ef3e --- /dev/null +++ b/pages/service-provider-detail/service-provider-detail.json @@ -0,0 +1,4 @@ +{ + "navigationStyle": "custom", + "usingComponents": {} +} diff --git a/pages/service-provider-detail/service-provider-detail.wxml b/pages/service-provider-detail/service-provider-detail.wxml new file mode 100644 index 0000000..0ed63e2 --- /dev/null +++ b/pages/service-provider-detail/service-provider-detail.wxml @@ -0,0 +1,308 @@ + + + + + + + + + + 服务详情 + + + + + + + + + + + + + + + + + + + + + 实名认证 + + + + + + + {{provider.name}} + {{provider.tag}} + + + + + + {{provider.experience}} + + + + {{provider.rating}} + + + + {{provider.desc}} + + + + + + + {{provider.hours}} + 服务时长 + + + + {{provider.serviceCount}} + 服务人次 + + + + {{provider.repeatRate}}% + 回头率 + + + + + + + + + 服务技能 + + + + + {{item}} + + + + + + + + + 服务介绍 + + {{provider.introduction}} + + + + + + + 服务案例 + + + + + {{item.title}} + + + + + + + + + + 用户评价 + ({{reviews.length}}) + + + 查看全部 + + + + + + + + + + {{item.date}} + + {{item.content}} + + + {{tag}} + + + + + + + + + + + + + + + ¥ + {{provider.price}} + {{provider.unit}} + + 起步价 + + + + + + 咨询 + + + 立即预约 + + + + + + + + + + 预约服务 + + × + + + + + + + + + {{provider.name}} + {{provider.tag}} + + + + + + 服务类型 + + + {{item.name}} + ¥{{item.price}}/{{item.unit}} + + + + + + + 预约时间 + + {{bookingTime || '请选择时间'}} + + + + + + + 服务地址 + + {{bookingAddress || '请选择地址'}} + + + + + + + 备注说明 + + {{remark.length}}/200 + + + + + + 服务费用 + ¥{{calculatePrice()}} + + + 平台服务费 + ¥0 + + + + 合计 + ¥{{calculatePrice()}} + + + + + + + + + + + + + + 全部评价 + + × + + + + + + + {{provider.rating}} + + + + {{reviews.length}}条评价 + + + {{provider.goodRate}}% + 好评率 + + + + + + + + + {{item.date}} + + {{item.content}} + + + {{tag}} + + + + + diff --git a/pages/service-provider-detail/service-provider-detail.wxss b/pages/service-provider-detail/service-provider-detail.wxss new file mode 100644 index 0000000..8abf09b --- /dev/null +++ b/pages/service-provider-detail/service-provider-detail.wxss @@ -0,0 +1,920 @@ +/* 服务人员详情页样式 */ +page { + background: #F9FAFB; + height: 100%; +} + +.page-container { + height: 100vh; + display: flex; + flex-direction: column; +} + +/* ========== 固定导航栏 ========== */ +.nav-container { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 999; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(10px); + box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04); +} + +.status-bar { + background: transparent; +} + +.nav-bar { + display: flex; + align-items: center; + justify-content: center; + padding: 0 32rpx; + background: transparent; + position: relative; +} + +.nav-back { + position: absolute; + left: 32rpx; + top: 50%; + transform: translateY(-50%); + width: 80rpx; + height: 80rpx; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +.back-icon { + width: 48rpx; + height: 48rpx; +} + +.nav-title { + font-size: 36rpx; + font-weight: 700; + color: #1F2937; + line-height: 1; +} + +.nav-share { + position: absolute; + right: 32rpx; + top: 50%; + transform: translateY(-50%); + width: 80rpx; + height: 80rpx; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +.share-icon { + width: 48rpx; + height: 48rpx; +} + +/* ========== 内容滚动区域 ========== */ +.content-scroll { + height: 100vh; + box-sizing: border-box; + padding-bottom: 240rpx; +} + +/* ========== 服务人员信息卡片 ========== */ +.provider-card { + background: #fff; + margin: 32rpx; + border-radius: 32rpx; + overflow: hidden; + box-shadow: 0 8rpx 24rpx -8rpx rgba(0, 0, 0, 0.08), 0 20rpx 30rpx -6rpx rgba(0, 0, 0, 0.06); + position: relative; +} + +.card-header-bg { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 280rpx; + background: linear-gradient(135deg, #FFF7ED 0%, #FEF3C7 50%, #FEE2E2 100%); + opacity: 0.6; +} + +.provider-info { + position: relative; + padding: 40rpx 32rpx 32rpx; + display: flex; + gap: 32rpx; +} + +.avatar-section { + flex-shrink: 0; +} + +.avatar-wrap { + width: 168rpx; + height: 168rpx; + position: relative; +} + +.avatar-img { + width: 168rpx; + height: 168rpx; + border-radius: 32rpx; + border: 4rpx solid #fff; + box-shadow: 0 8rpx 16rpx -4rpx rgba(0, 0, 0, 0.1); +} + +.verified-badge { + position: absolute; + bottom: -16rpx; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 8rpx; + padding: 6rpx 20rpx; + background: linear-gradient(135deg, #FFFBEB 0%, #FEF3C7 100%); + border: 2rpx solid #FCD34D; + border-radius: 100rpx; + box-shadow: 0 4rpx 8rpx -2rpx rgba(0, 0, 0, 0.1); +} + +.verified-icon { + width: 24rpx; + height: 24rpx; +} + +.verified-text { + font-size: 20rpx; + font-weight: 700; + color: #B45309; +} + +.info-section { + flex: 1; + display: flex; + flex-direction: column; + gap: 16rpx; + padding-top: 8rpx; +} + +.name-row { + display: flex; + align-items: center; + gap: 16rpx; +} + +.provider-name { + font-size: 44rpx; + font-weight: 900; + color: #101828; + line-height: 1.2; +} + +.provider-tag { + padding: 6rpx 16rpx; + background: linear-gradient(135deg, #914584 0%, #B066A3 100%); + border-radius: 100rpx; + font-size: 22rpx; + font-weight: 700; + color: #fff; +} + +.experience-row { + display: flex; + align-items: center; + gap: 24rpx; +} + +.experience-item { + display: flex; + align-items: center; + gap: 8rpx; +} + +.exp-icon { + width: 28rpx; + height: 28rpx; +} + +.exp-text { + font-size: 28rpx; + font-weight: 600; + color: #4A5565; +} + +.rating-item { + display: flex; + align-items: center; + gap: 8rpx; +} + +.star-icon { + width: 28rpx; + height: 28rpx; +} + +.rating-text { + font-size: 28rpx; + font-weight: 700; + color: #F59E0B; +} + +.provider-desc { + font-size: 28rpx; + color: #6A7282; + line-height: 1.5; +} + +/* 统计数据 */ +.stats-section { + display: flex; + justify-content: space-around; + padding: 32rpx; + border-top: 2rpx solid #F3F4F6; + background: linear-gradient(180deg, rgba(255, 247, 237, 0.3) 0%, rgba(254, 243, 199, 0.3) 100%); +} + +.stat-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 8rpx; +} + +.stat-value { + font-size: 48rpx; + font-weight: 900; + color: #101828; + line-height: 1; +} + +.stat-label { + font-size: 24rpx; + color: #6A7282; +} + +.stat-divider { + width: 2rpx; + height: 80rpx; + background: #E5E7EB; +} + +/* ========== 通用卡片样式 ========== */ +.skills-card, +.intro-card, +.cases-card, +.reviews-card { + background: #fff; + margin: 0 32rpx 32rpx; + padding: 32rpx; + border-radius: 24rpx; + box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04); +} + +.card-title { + display: flex; + align-items: center; + gap: 16rpx; + margin-bottom: 24rpx; +} + +.title-dot { + width: 8rpx; + height: 8rpx; + background: #914584; + border-radius: 50%; +} + +.title-text { + font-size: 36rpx; + font-weight: 900; + color: #101828; +} + +.card-title-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24rpx; +} + +.review-count { + font-size: 28rpx; + color: #99A1AF; + margin-left: 8rpx; +} + +.view-all { + display: flex; + align-items: center; + gap: 8rpx; +} + +.view-all-text { + font-size: 28rpx; + color: #914584; +} + +.arrow-icon { + width: 32rpx; + height: 32rpx; +} + +/* ========== 服务技能 ========== */ +.skills-list { + display: flex; + flex-wrap: wrap; + gap: 20rpx; +} + +.skill-tag { + display: flex; + align-items: center; + gap: 12rpx; + padding: 16rpx 24rpx; + background: linear-gradient(135deg, #F0FDF4 0%, #DCFCE7 100%); + border: 2rpx solid #86EFAC; + border-radius: 100rpx; +} + +.check-icon { + width: 28rpx; + height: 28rpx; +} + +.skill-text { + font-size: 28rpx; + font-weight: 600; + color: #166534; +} + +/* ========== 服务介绍 ========== */ +.intro-content { + font-size: 30rpx; + color: #4A5565; + line-height: 1.8; +} + +/* ========== 服务案例 ========== */ +.cases-list { + display: flex; + gap: 24rpx; + overflow-x: auto; +} + +.case-item { + flex-shrink: 0; + width: 280rpx; +} + +.case-image { + width: 280rpx; + height: 280rpx; + border-radius: 16rpx; + margin-bottom: 16rpx; +} + +.case-title { + font-size: 28rpx; + color: #364153; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ========== 用户评价 ========== */ +.review-list { + display: flex; + flex-direction: column; + gap: 32rpx; +} + +.review-item { + padding-bottom: 32rpx; + border-bottom: 2rpx solid #F3F4F6; +} + +.review-item:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.review-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 16rpx; +} + +.user-info { + display: flex; + gap: 16rpx; +} + +.user-avatar { + width: 72rpx; + height: 72rpx; + border-radius: 50%; +} + +.user-detail { + display: flex; + flex-direction: column; + gap: 8rpx; +} + +.user-name { + font-size: 28rpx; + font-weight: 600; + color: #364153; +} + +.review-stars { + display: flex; + gap: 4rpx; +} + +.star-small { + width: 24rpx; + height: 24rpx; +} + +.review-date { + font-size: 24rpx; + color: #99A1AF; +} + +.review-content { + font-size: 28rpx; + color: #4A5565; + line-height: 1.6; + margin-bottom: 16rpx; +} + +.review-tags { + display: flex; + flex-wrap: wrap; + gap: 16rpx; +} + +.review-tag { + padding: 8rpx 20rpx; + background: #FFF7ED; + border-radius: 100rpx; +} + +.tag-text { + font-size: 24rpx; + color: #EA580C; +} + +/* ========== 底部占位 ========== */ +.bottom-placeholder { + height: 80rpx; +} + +/* ========== 底部操作栏 ========== */ +.bottom-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: #fff; + padding: 24rpx 32rpx; + padding-bottom: calc(24rpx + env(safe-area-inset-bottom)); + border-top: 2rpx solid #F3F4F6; + display: flex; + justify-content: space-between; + align-items: center; + box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.04); + z-index: 100; +} + +.price-section { + display: flex; + flex-direction: column; + gap: 4rpx; +} + +.price-row { + display: flex; + align-items: baseline; +} + +.price-symbol { + font-size: 28rpx; + font-weight: 700; + color: #FF6B00; +} + +.price-value { + font-size: 56rpx; + font-weight: 900; + color: #FF6B00; + line-height: 1; +} + +.price-unit { + font-size: 24rpx; + color: #6A7282; + margin-left: 8rpx; +} + +.price-tip { + font-size: 24rpx; + color: #99A1AF; +} + +.action-buttons { + display: flex; + gap: 16rpx; +} + +.consult-btn { + width: 120rpx; + height: 88rpx; + background: #F3F4F6; + border-radius: 100rpx; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4rpx; +} + +.btn-icon { + width: 36rpx; + height: 36rpx; +} + +.consult-btn .btn-text { + font-size: 24rpx; + color: #4A5565; +} + +.book-btn { + width: 240rpx; + height: 88rpx; + background: linear-gradient(135deg, #FF6B6B 0%, #FF8787 100%); + border-radius: 100rpx; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 8rpx 20rpx -6rpx rgba(255, 107, 107, 0.4); +} + +.book-btn .btn-text { + font-size: 32rpx; + font-weight: 700; + color: #fff; +} + +/* ========== 预约弹窗 ========== */ +.booking-modal-mask { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; +} + +.booking-modal { + position: fixed; + left: 0; + right: 0; + bottom: 0; + background: #fff; + border-radius: 32rpx 32rpx 0 0; + z-index: 1001; + max-height: 85vh; + display: flex; + flex-direction: column; + transform: translateY(100%); + transition: transform 0.3s ease; +} + +.booking-modal.show { + transform: translateY(0); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 32rpx; + border-bottom: 2rpx solid #F3F4F6; +} + +.modal-title { + font-size: 40rpx; + font-weight: 900; + color: #101828; +} + +.modal-close { + width: 64rpx; + height: 64rpx; + display: flex; + align-items: center; + justify-content: center; + background: #F3F4F6; + border-radius: 50%; +} + +.close-icon { + font-size: 48rpx; + color: #6A7282; + line-height: 1; +} + +.modal-content { + flex: 1; + padding: 32rpx; + overflow-y: auto; +} + +/* 预约表单 */ +.booking-provider { + display: flex; + align-items: center; + gap: 24rpx; + padding: 24rpx; + background: #F9FAFB; + border-radius: 16rpx; + margin-bottom: 32rpx; +} + +.provider-avatar-small { + width: 96rpx; + height: 96rpx; + border-radius: 16rpx; +} + +.provider-info-small { + display: flex; + flex-direction: column; + gap: 8rpx; +} + +.provider-name-small { + font-size: 32rpx; + font-weight: 700; + color: #101828; +} + +.provider-tag-small { + font-size: 24rpx; + color: #6A7282; +} + +.form-section { + margin-bottom: 32rpx; +} + +.form-label { + display: block; + font-size: 28rpx; + font-weight: 600; + color: #364153; + margin-bottom: 16rpx; +} + +.service-type-list { + display: flex; + flex-direction: column; + gap: 16rpx; +} + +.type-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 24rpx; + background: #F9FAFB; + border: 2rpx solid #E5E7EB; + border-radius: 16rpx; + transition: all 0.2s; +} + +.type-item.active { + background: #FFF7ED; + border-color: #FF6B00; +} + +.type-name { + font-size: 30rpx; + font-weight: 600; + color: #364153; +} + +.type-price { + font-size: 28rpx; + font-weight: 700; + color: #FF6B00; +} + +.time-picker, +.address-picker { + display: flex; + justify-content: space-between; + align-items: center; + padding: 24rpx; + background: #F9FAFB; + border: 2rpx solid #E5E7EB; + border-radius: 16rpx; +} + +.time-text, +.address-text { + font-size: 30rpx; + color: #364153; +} + +.calendar-icon, +.location-icon { + width: 40rpx; + height: 40rpx; +} + +.remark-input { + width: 100%; + min-height: 160rpx; + padding: 24rpx; + background: #F9FAFB; + border: 2rpx solid #E5E7EB; + border-radius: 16rpx; + font-size: 28rpx; + color: #364153; + box-sizing: border-box; +} + +.char-count { + display: block; + text-align: right; + font-size: 24rpx; + color: #99A1AF; + margin-top: 8rpx; +} + +.price-summary { + padding: 24rpx; + background: #F9FAFB; + border-radius: 16rpx; + margin-top: 32rpx; +} + +.summary-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16rpx; +} + +.summary-row:last-child { + margin-bottom: 0; +} + +.summary-label { + font-size: 28rpx; + color: #6A7282; +} + +.summary-value { + font-size: 32rpx; + font-weight: 700; + color: #364153; +} + +.summary-row.total .summary-label { + font-size: 32rpx; + font-weight: 700; + color: #101828; +} + +.summary-row.total .summary-value { + font-size: 40rpx; + color: #FF6B00; +} + +.summary-divider { + height: 2rpx; + background: #E5E7EB; + margin: 16rpx 0; +} + +.modal-footer { + padding: 24rpx 32rpx; + padding-bottom: calc(24rpx + env(safe-area-inset-bottom)); + border-top: 2rpx solid #F3F4F6; +} + +.confirm-btn { + width: 100%; + height: 96rpx; + background: linear-gradient(135deg, #FF6B6B 0%, #FF8787 100%); + border-radius: 100rpx; + font-size: 32rpx; + font-weight: 700; + color: #fff; + border: none; + box-shadow: 0 8rpx 20rpx -6rpx rgba(255, 107, 107, 0.4); +} + +.confirm-btn::after { + border: none; +} + +/* ========== 全部评价弹窗 ========== */ +.all-reviews-modal-mask { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; +} + +.all-reviews-modal { + position: fixed; + left: 0; + right: 0; + bottom: 0; + background: #fff; + border-radius: 32rpx 32rpx 0 0; + z-index: 1001; + max-height: 85vh; + display: flex; + flex-direction: column; + transform: translateY(100%); + transition: transform 0.3s ease; +} + +.all-reviews-modal.show { + transform: translateY(0); +} + +.review-summary { + display: flex; + justify-content: space-between; + align-items: center; + padding: 32rpx; + border-bottom: 2rpx solid #F3F4F6; +} + +.summary-left { + display: flex; + flex-direction: column; + gap: 8rpx; +} + +.summary-score { + font-size: 72rpx; + font-weight: 900; + color: #101828; + line-height: 1; +} + +.summary-stars { + display: flex; + gap: 8rpx; +} + +.summary-count { + font-size: 24rpx; + color: #99A1AF; +} + +.summary-right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4rpx; +} + +.good-rate { + font-size: 56rpx; + font-weight: 900; + color: #FF6B00; + line-height: 1; +} + +.good-rate-label { + font-size: 24rpx; + color: #99A1AF; +} + +.all-reviews-list { + flex: 1; + padding: 32rpx; + overflow-y: auto; +} diff --git a/pages/service/service.js b/pages/service/service.js new file mode 100644 index 0000000..6354431 --- /dev/null +++ b/pages/service/service.js @@ -0,0 +1,293 @@ +// 服务页面 - 按照Figma设计实现 +const api = require('../../utils/api') +const config = require('../../config/index') + +Page({ + data: { + statusBarHeight: 44, + navBarHeight: 44, + totalNavHeight: 88, + searchText: '', + hasNotification: true, + totalUnread: 0, + + // 轮播图 - 从后台素材管理API加载 + banners: [], + swiperHeight: 400, + + // 服务类型 - 6个 + serviceTypes: [ + { id: 'points', name: '礼品商城', icon: '/images/fuw-shangcheng.png', bgColor: '#FFF7ED' }, + { id: 'merchants', name: '合作商家', icon: '/images/fuw-shangjia.png', bgColor: '#FFF1F2' }, + { id: 'eldercare', name: '智慧康养', icon: '/images/fuw-kangyang.png', bgColor: '#F0FDFA' }, + { id: 'custom', name: '定制服务', icon: '/images/fuw-dingzhi.png', bgColor: '#F0F9FF' }, + { id: 'academy', name: '心伴学堂', icon: '/images/fuw-pinpai.png', bgColor: '#F5F3FF' }, + { id: 'brand', name: '关于品牌', icon: '/images/fuw-aixin.png', bgColor: '#FFF1F2' } + ], + + // 内容列表 (通用) + listData: [], + isLoading: false, + page: 1, + hasMore: true, + + // 审核状态 + auditStatus: 0 + }, + + onLoad() { + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + const menuButton = wx.getMenuButtonBoundingClientRect() + const navBarHeight = menuButton.height + (menuButton.top - statusBarHeight) * 2 + const totalNavHeight = statusBarHeight + navBarHeight + + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight + }) + + // 加载Banner配置 + this.loadBanners() + + // 加载合作商家列表 + this.loadListData() + }, + + /** + * 轮播图图片加载完成,自适应高度 + */ + onBannerLoad(e) { + if (this.data.swiperHeight !== 400) return; // 只计算一次,避免多次抖动 + const { width, height } = e.detail; + const sysInfo = wx.getSystemInfoSync(); + // 减去左右padding (32rpx * 2) + const swiperWidth = sysInfo.windowWidth - (32 * 2 / 750 * sysInfo.windowWidth); + const ratio = width / height; + const swiperHeight = swiperWidth / ratio; + const swiperHeightRpx = swiperHeight * (750 / sysInfo.windowWidth); + + this.setData({ + swiperHeight: swiperHeightRpx + }); + }, + + /** + * 处理图片URL,如果是相对路径则拼接域名,并设置清晰度为85 + */ + processImageUrl(url) { + if (!url) return '' + let fullUrl = url + if (!url.startsWith('http://') && !url.startsWith('https://')) { + const baseUrl = 'https://ai-c.maimanji.com' + fullUrl = baseUrl + (url.startsWith('/') ? '' : '/') + url + } + + // 添加清晰度参数 q=85 + if (fullUrl.includes('?')) { + if (!fullUrl.includes('q=')) { + fullUrl += '&q=85' + } + } else { + fullUrl += '?q=85' + } + return fullUrl + }, + + /** + * 加载合作商家列表 + */ + async loadListData(reset = true) { + if (this.data.isLoading) return + if (!reset && !this.data.hasMore) return + + this.setData({ isLoading: true }) + const page = reset ? 1 : this.data.page + 1 + + try { + // 加载合作商家 (Merchants) + const res = await api.request('/service/providers', { + data: { page, limit: 20, type: 'merchant' } + }) + + let newData = [] + let total = 0 + + if (res.success && res.data) { + newData = res.data.providers || [] + total = res.data.total || 0 + newData = newData.map(item => ({ + ...item, + id: item.id, + image: this.processImageUrl(item.avatar), + tags: item.skills || item.service_types || [], + desc: item.desc || item.introduction || '暂无简介', + rating: item.rating || item.avg_rating || '5.0' + })) + } + + this.setData({ + listData: reset ? newData : [...this.data.listData, ...newData], + page, + hasMore: (page * 20) < total, + isLoading: false + }) + } catch (err) { + if (err.code === 404) { + console.warn('列表接口未部署 (404), 显示空列表') + } else { + console.error('加载列表数据失败', err) + } + this.setData({ + listData: reset ? [] : this.data.listData, + isLoading: false, + hasMore: false + }) + } + }, + + /** + * 页面上拉触底事件的处理函数 + */ + onReachBottom() { + this.loadListData(false) + }, + + onShow() { + wx.hideTabBar({ animation: false }) + const app = getApp() + this.setData({ + auditStatus: app.globalData.auditStatus + }) + this.loadListData() + this.loadUnreadCount() + }, + + /** + * 加载服务页Banner + * 调用专用API,只返回在线的Banner,按排序顺序 + */ + async loadBanners() { + try { + const res = await api.pageAssets.getServiceBanners() + + if (res && res.success && res.data) { + // 提取URL数组(API已按排序返回,已过滤下线的) + const banners = res.data.map(item => this.processImageUrl(item.asset_url)) + + if (banners.length > 0) { + this.setData({ banners }) + } else { + // 如果全部下线,使用默认配置 + this.setDefaultBanners() + } + } else { + this.setDefaultBanners() + } + } catch (err) { + if (err.code !== 404) { + console.error('加载Banner失败:', err) + } + this.setDefaultBanners() + } + }, + + /** + * 设置默认Banner(降级方案 - 使用CDN URL) + */ + setDefaultBanners() { + const cdnBase = 'https://ai-c.maimanji.com/images' + this.setData({ + banners: [ + `${cdnBase}/service-banner-1.png`, + `${cdnBase}/service-banner-2.png`, + `${cdnBase}/service-banner-3.png`, + `${cdnBase}/service-banner-4.png`, + `${cdnBase}/service-banner-5.png`, + `${cdnBase}/service-banner-6.png` + ] + }) + console.log('使用默认Banner配置') + }, + + /** + * 加载未读消息数 + */ + loadUnreadCount() { + if (!api.chat || typeof api.chat.getConversations !== 'function') return + + api.chat.getConversations().then(res => { + if (res && res.success && res.data) { + let count = 0 + res.data.forEach(item => { + count += (item.unread_count || 0) + }) + this.setData({ totalUnread: count }) + } + }).catch(err => { + // 静默处理未读数获取失败 + if (err.code !== 404) { + console.warn('获取未读数静默失败:', err) + } + }) + }, + + /** + * 点击服务类型 + */ + onServiceType(e) { + const id = e.currentTarget.dataset.id + + if (id === 'points') { + // 礼品商城 - 跳转到兑换商城 + wx.navigateTo({ url: '/pages/gift-shop/gift-shop' }) + } else if (id === 'merchants') { + // 合作商家 - 刷新列表 + this.loadListData() + } else if (id === 'eldercare') { + // 智慧康养 - 跳转到详情页 + wx.navigateTo({ url: '/pages/eldercare/eldercare' }) + } else if (id === 'custom') { + // 定制服务 - 跳转到详情页 + wx.navigateTo({ url: '/pages/custom/custom' }) + } else if (id === 'academy') { + // 心伴学堂 - 跳转到列表页 + wx.navigateTo({ url: '/pages/academy/list/list' }) + } else if (id === 'brand') { + // 关于品牌 - 跳转到品牌详情页 + wx.navigateTo({ url: '/pages/brand/brand' }) + } + }, + + /** + * 点击列表项 - 跳转商家详情 + */ + onItemTap(e) { + const id = e.currentTarget.dataset.id + wx.navigateTo({ + url: `/pages/service-provider-detail/service-provider-detail?id=${id}` + }) + }, + + /** + * 切换Tab + */ + switchTab(e) { + const path = e.currentTarget.dataset.path + if (path) { + const app = getApp() + + // 消息页面需要登录 + if (path === '/pages/chat/chat') { + if (!app.globalData.isLoggedIn) { + wx.navigateTo({ + url: '/pages/login/login?redirect=' + encodeURIComponent(path) + }) + return + } + } + wx.switchTab({ url: path }) + } + } +}) diff --git a/pages/service/service.json b/pages/service/service.json new file mode 100644 index 0000000..cd29add --- /dev/null +++ b/pages/service/service.json @@ -0,0 +1,5 @@ +{ + "usingComponents": {}, + "navigationBarTitleText": "服务", + "navigationStyle": "custom" +} diff --git a/pages/service/service.wxml b/pages/service/service.wxml new file mode 100644 index 0000000..56ccc8b --- /dev/null +++ b/pages/service/service.wxml @@ -0,0 +1,98 @@ + + + + + + 综合服务 + + + + + + + + + + + + + {{item.name}} + + + + + + + 精选服务 + + + + + + + + + + + + + + + + {{item.name}} + + + {{item.rating || '5.0'}} + + + {{item.desc}} + + {{tag}} + + + + + + + + 暂无合作商家 + + + + + + + + + + + + 陪伴 + + + + 文娱 + + + + 服务 + + + + + + + 消息 + + + + 我的 + + + diff --git a/pages/service/service.wxss b/pages/service/service.wxss new file mode 100644 index 0000000..8312051 --- /dev/null +++ b/pages/service/service.wxss @@ -0,0 +1,570 @@ +/* 服务页面样式 - 按照Figma设计实现 */ +page { + width: 100%; + overflow-x: hidden; + background: #fff; +} + +.page-container { + min-height: 100vh; + display: flex; + flex-direction: column; + width: 100%; + overflow-x: hidden; + background: #fff; +} + +/* 顶部导航栏已移除,改用全局 unified-header */ + +/* 内容滚动区域 */ +.content-scroll { + flex: 1; + width: 100%; +} + +/* 搜索栏 - 常规样式 */ +.search-section { + display: flex; + align-items: center; + padding: 0 32rpx; + margin-top: 16rpx; +} + +.search-bar { + flex: 1; + display: flex; + align-items: center; + height: 80rpx; + background: #F5F7FA; + border-radius: 40rpx; + padding: 0 24rpx; +} + +.search-icon { + width: 32rpx; + height: 32rpx; + flex-shrink: 0; + opacity: 0.5; +} + +.search-input-wrap { + flex: 1; + margin: 0 16rpx; +} + +.search-input { + width: 100%; + height: 80rpx; + font-size: 28rpx; + color: #101828; +} + +.placeholder { + color: #99A1AF; + font-size: 28rpx; +} + +.search-btn { + padding: 12rpx 32rpx; + background: #914584; + border-radius: 32rpx; + color: #fff; + font-size: 28rpx; + font-weight: 600; + flex-shrink: 0; +} + +/* 轮播图 Banner */ +.banner-section { + padding: 24rpx 32rpx; +} + +.banner-swiper { + width: 100%; + height: 300rpx; + border-radius: 32rpx; + overflow: hidden; + box-shadow: 0 8rpx 24rpx -8rpx rgba(243, 244, 246, 1), 0 20rpx 30rpx -6rpx rgba(243, 244, 246, 1); +} + +/* 防止轮播图闪烁 */ +swiper-item { + will-change: transform; + backface-visibility: hidden; + -webkit-backface-visibility: hidden; +} + +.banner-image { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + /* 防止图片闪烁 */ + backface-visibility: hidden; + -webkit-backface-visibility: hidden; + transform: translateZ(0); + -webkit-transform: translateZ(0); +} + +/* 服务类型 6宫格 - 按Figma设计: 358.77x239.94, 3列2行 */ +.service-types { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + width: 718rpx; + margin: 0 auto; + padding: 24rpx 0; + row-gap: 64rpx; +} + +.service-type-item { + width: 218rpx; + height: 208rpx; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + gap: 24rpx; + box-sizing: border-box; +} + +.service-type-icon { + width: 168rpx; + height: 168rpx; + border-radius: 9999rpx; + flex-shrink: 0; +} + +.service-type-name { + font-family: Arial, sans-serif; + font-size: 36rpx; + font-weight: 700; + color: #364153; + line-height: 1.56; + text-align: center; +} + +/* 精选服务区域 */ +.featured-section { + background: #fff; + border-top: 2rpx solid #F9FAFB; + padding: 48rpx 32rpx 0; +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 32rpx; +} + +.section-title { + font-family: Arial, sans-serif; + font-size: 40rpx; + font-weight: 900; + color: #101828; + line-height: 1.33; +} + +.view-more { + display: flex; + align-items: center; + gap: 0; +} + +.more-text { + font-family: Arial, sans-serif; + font-size: 32rpx; + color: #6A7282; +} + +.arrow-icon { + width: 40rpx; + height: 40rpx; +} + +/* 分类标签 */ +.category-tabs { + width: 100%; + white-space: nowrap; + margin-bottom: 8rpx; +} + +.tabs-container { + display: inline-flex; + gap: 64rpx; +} + +.tab-item { + display: inline-flex; + flex-direction: column; + align-items: center; + position: relative; + padding-bottom: 8rpx; +} + +.tab-text { + font-family: Arial, sans-serif; + font-size: 32rpx; + color: #4A5565; +} + +.tab-item.active .tab-text { + color: #914584; +} + +.tab-indicator { + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 40rpx; + height: 4rpx; + background: #914584; + border-radius: 9999rpx; +} + +/* 服务人员列表 */ +.provider-list { + padding: 32rpx; +} + +.provider-card { + display: flex; + background: #fff; + border: 2rpx solid #F3F4F6; + border-radius: 32rpx; + padding: 32rpx; + margin-bottom: 32rpx; + box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04); +} + +/* 左侧头像区域 */ +.provider-left { + width: 168rpx; + flex-shrink: 0; + margin-right: 32rpx; + position: relative; +} + +.provider-top-tags { + display: flex; + flex-direction: column; + align-items: center; + gap: 8rpx; + margin-bottom: 16rpx; +} + +.experience-tag { + width: 100%; + height: 40rpx; + background: #F9FAFB; + border-radius: 16rpx; + font-family: Arial, sans-serif; + font-size: 24rpx; + font-weight: 700; + color: #364153; + display: flex; + align-items: center; + justify-content: center; + letter-spacing: -2.5%; +} + +.rating-wrap { + display: flex; + align-items: center; + gap: 8rpx; +} + +.star-icon { + width: 20rpx; + height: 20rpx; +} + +.rating-text { + font-family: Arial, sans-serif; + font-size: 22rpx; + font-weight: 700; + color: #364153; +} + +.avatar-container { + display: flex; + flex-direction: column; + align-items: center; +} + +.avatar-wrapper { + width: 168rpx; + height: 168rpx; + border-radius: 32rpx; + background: #F3F4F6; + overflow: hidden; +} + +.provider-avatar { + width: 100%; + height: 100%; +} + +.verified-badge { + margin-top: -24rpx; + padding: 4rpx 18rpx; + background: #FFFAF0; + border: 2rpx solid #FFE4BA; + border-radius: 8rpx; + font-family: Arial, sans-serif; + font-size: 20rpx; + font-weight: 700; + color: #B7791F; + box-shadow: 0 2rpx 4rpx -2rpx rgba(0, 0, 0, 0.1), 0 2rpx 6rpx rgba(0, 0, 0, 0.1); + position: relative; + z-index: 1; +} + +/* 右侧信息区域 */ +.provider-right { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 24rpx; +} + +.provider-header { + display: flex; + align-items: center; + gap: 16rpx; + height: 56rpx; +} + +.provider-name { + font-family: Arial, sans-serif; + font-size: 40rpx; + font-weight: 900; + color: #101828; + line-height: 1.4; +} + +.provider-tag { + padding: 4rpx 12rpx; + background: linear-gradient(180deg, #914584 0%, #B066A3 100%); + border-radius: 16rpx; + font-family: Arial, sans-serif; + font-size: 20rpx; + color: #fff; + letter-spacing: 2.5%; +} + +.provider-desc { + font-family: Arial, sans-serif; + font-size: 24rpx; + color: #6A7282; + line-height: 1.33; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.provider-skills { + display: flex; + gap: 12rpx; + flex-wrap: wrap; +} + +.skill-tag { + height: 48rpx; + padding: 0 16rpx; + background: #F5F7FA; + border-radius: 12rpx; + font-family: Arial, sans-serif; + font-size: 24rpx; + color: #4A5565; + display: flex; + align-items: center; + justify-content: center; +} + +.skill-tag.has-icon { + gap: 8rpx; +} + +.skill-icon { + width: 24rpx; + height: 24rpx; +} + +.provider-footer { + display: flex; + align-items: flex-end; + justify-content: space-between; + padding-top: 16rpx; + border-top: 2rpx solid #F9FAFB; +} + +.price-section { + display: flex; + align-items: baseline; +} + +.price-symbol { + font-family: Arial, sans-serif; + font-size: 24rpx; + color: #FF6B00; +} + +.price-value { + font-family: Arial, sans-serif; + font-size: 40rpx; + font-weight: 900; + color: #FF6B00; + line-height: 1.4; +} + +.price-unit { + font-family: Arial, sans-serif; + font-size: 24rpx; + color: #6A7282; + margin-left: 4rpx; +} + +.book-btn { + width: 176rpx; + height: 64rpx; + background: linear-gradient(180deg, #FF6B6B 0%, #FF8787 100%); + border-radius: 9999rpx; + font-family: Arial, sans-serif; + font-size: 28rpx; + font-weight: 700; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2rpx 4rpx -2rpx #FFEDD4, 0 2rpx 6rpx #FFEDD4; +} + +/* 暂无内容提示 */ +.empty-tip { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 100rpx 0; + gap: 20rpx; +} + +.empty-tip image { + width: 200rpx; + height: 200rpx; + opacity: 0.5; +} + +.empty-tip text { + font-size: 28rpx; + color: #99A1AF; +} + +/* 商家评分样式 */ +.merchant-rating { + display: flex; + align-items: center; + gap: 8rpx; + margin-left: auto; +} + +.merchant-rating .star-icon { + width: 32rpx; + height: 32rpx; +} + +.merchant-rating .rating-value { + font-size: 36rpx; + font-weight: 700; + color: #FF6B00; +} + +/* 智慧康养状态标签 */ +.provider-status { + display: flex; + margin-top: 10rpx; +} + +.status-tag { + padding: 4rpx 16rpx; + background-color: #F3F4F6; + color: #99A1AF; + font-size: 22rpx; + border-radius: 8rpx; +} + +/* 底部占位 */ +.bottom-placeholder { + height: 200rpx; +} + + +/* 自定义底部导航栏 - 与其他页面统一 */ +.custom-tabbar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 194rpx; + background: #fff; + display: flex; + align-items: flex-start; + justify-content: space-around; + padding-top: 24rpx; + z-index: 999; + border-top: 2rpx solid #F3F4F6; +} + +.tabbar-item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12rpx; + width: 150rpx; + height: 120rpx; +} + +.tabbar-icon { + width: 68rpx; + height: 68rpx; +} + +.tabbar-text { + font-family: Arial, sans-serif; + font-size: 40rpx; + font-weight: 700; + color: #A58AA5; + line-height: 1; +} + +.tabbar-text.active { + color: #B06AB3; +} + +.message-icon-wrapper { + position: relative; + width: 68rpx; + height: 68rpx; +} + +.message-icon-wrapper .tabbar-icon { + width: 68rpx; + height: 68rpx; +} + +.message-dot { + position: absolute; + top: -8rpx; + right: -8rpx; + width: 24rpx; + height: 24rpx; + background: #FB2C36; + border: 2rpx solid #fff; + border-radius: 50%; +} diff --git a/pages/settings/settings.js b/pages/settings/settings.js new file mode 100644 index 0000000..6245004 --- /dev/null +++ b/pages/settings/settings.js @@ -0,0 +1,72 @@ +const { login } = require('../../utils_new/auth'); +const config = require('../../config/index'); + +Page({ + data: { + baseUrl: 'https://ai-c.maimanji.com', + statusBarHeight: 20, + navBarHeight: 44, + totalNavHeight: 64 + }, + onLoad() { + const sys = wx.getSystemInfoSync(); + const menu = wx.getMenuButtonBoundingClientRect(); + const statusBarHeight = sys.statusBarHeight || 20; + const navBarHeight = menu.height + (menu.top - statusBarHeight) * 2; + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight: statusBarHeight + navBarHeight + }); + this.load(); + }, + onShow() { + const defaultBaseUrl = String(config.API_BASE_URL || '').replace(/\/api$/, '') || 'https://ai-c.maimanji.com'; + const baseUrl = wx.getStorageSync('baseUrl') || defaultBaseUrl; + this.setData({ baseUrl }); + }, + onBack() { + wx.navigateBack({ delta: 1 }); + }, + onBaseUrl(e) { + this.setData({ baseUrl: e.detail.value }); + }, + setOnline() { + this.setData({ baseUrl: 'https://ai-c.maimanji.com' }); + }, + setLocalhost() { + this.setData({ baseUrl: 'http://localhost:3000' }); + }, + save() { + const baseUrl = (this.data.baseUrl || '').trim().replace(/\/$/, ''); + if (!baseUrl.startsWith('http')) { + wx.showToast({ title: '请输入正确的URL', icon: 'none' }); + return; + } + wx.setStorageSync('baseUrl', baseUrl); + const app = getApp(); + if (app?.globalData) app.globalData.baseUrl = baseUrl; + wx.showToast({ title: '已保存', icon: 'success' }); + }, + logout() { + wx.removeStorageSync('token'); + const app = getApp(); + if (app?.globalData) app.globalData.token = ''; + wx.showToast({ title: '已退出', icon: 'success' }); + }, + async syncProfile() { + try { + const userInfo = await new Promise((resolve, reject) => { + wx.getUserProfile({ + desc: '用于完善个人信息', + success: (res) => resolve(res.userInfo), + fail: reject + }); + }); + await login(userInfo); + wx.showToast({ title: '已同步', icon: 'success' }); + } catch (e) { + wx.showToast({ title: '取消或失败', icon: 'none' }); + } + } +}); diff --git a/pages/settings/settings.json b/pages/settings/settings.json new file mode 100644 index 0000000..3153ca5 --- /dev/null +++ b/pages/settings/settings.json @@ -0,0 +1,5 @@ +{ + "usingComponents": { + "app-icon": "/components/icon/icon" + } +} diff --git a/pages/settings/settings.wxml b/pages/settings/settings.wxml new file mode 100644 index 0000000..a1c08fa --- /dev/null +++ b/pages/settings/settings.wxml @@ -0,0 +1,31 @@ + + + + + + + + 设置 + + + + + + + 接口地址 + + + + + + 用于切换测试/正式环境 + + + + + + + + + + diff --git a/pages/settings/settings.wxss b/pages/settings/settings.wxss new file mode 100644 index 0000000..ffff3e0 --- /dev/null +++ b/pages/settings/settings.wxss @@ -0,0 +1,141 @@ +.page { + min-height: 100vh; + background: #E8C3D4; +} + +.nav-container { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 999; + background: rgba(248, 249, 252, 0.75); + backdrop-filter: blur(20rpx) saturate(180%); + -webkit-backdrop-filter: blur(20rpx) saturate(180%); +} + +.status-bar { + background: transparent; +} + +.nav-bar { + display: flex; + align-items: center; + justify-content: center; + padding: 0 32rpx; + position: relative; +} + +.nav-back { + position: absolute; + left: 32rpx; + top: 50%; + transform: translateY(-50%); + width: 80rpx; + height: 80rpx; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +.back-icon { + width: 48rpx; + height: 48rpx; +} + +.nav-title { + font-size: 34rpx; + font-weight: 700; + color: #1A1A1A; +} + +.wrap { + padding: 24rpx; +} + +.card { + background: #ffffff; + border-radius: 40rpx; + padding: 28rpx; + box-shadow: 0 10rpx 20rpx rgba(17, 24, 39, 0.04); + margin-bottom: 24rpx; +} + +.field { + margin-bottom: 18rpx; +} + +.label { + display: block; + font-size: 24rpx; + font-weight: 900; + color: #111827; + margin-bottom: 12rpx; +} + +.input { + height: 88rpx; + border-radius: 24rpx; + background: #f9fafb; + padding: 0 24rpx; + font-size: 26rpx; + font-weight: 800; + color: #111827; +} + +.hint { + display: block; + margin-top: 10rpx; + font-size: 22rpx; + color: #9ca3af; + font-weight: 700; +} + +.presets { + display: flex; + gap: 12rpx; + margin-top: 16rpx; + flex-wrap: wrap; +} + +.preset { + padding: 14rpx 18rpx; + border-radius: 999rpx; + background: rgba(17, 24, 39, 0.06); + color: #111827; + font-size: 24rpx; + font-weight: 800; +} + +.save { + width: 100%; + padding: 26rpx 0; + border-radius: 24rpx; + background: #b06ab3; + color: #ffffff; + font-size: 32rpx; + font-weight: 900; + box-shadow: 0 22rpx 44rpx rgba(176, 106, 179, 0.25); +} + +.logout { + width: 100%; + padding: 26rpx 0; + border-radius: 24rpx; + background: rgba(239, 68, 68, 0.1); + color: #ef4444; + font-size: 32rpx; + font-weight: 900; + margin-bottom: 16rpx; +} + +.login { + width: 100%; + padding: 26rpx 0; + border-radius: 24rpx; + background: rgba(176, 106, 179, 0.1); + color: #b06ab3; + font-size: 32rpx; + font-weight: 900; +} diff --git a/pages/singles-party/singles-party.js b/pages/singles-party/singles-party.js new file mode 100644 index 0000000..86b55e1 --- /dev/null +++ b/pages/singles-party/singles-party.js @@ -0,0 +1,326 @@ +// pages/singles-party/singles-party.js - 单身聚会页面 +const api = require('../../utils/api') +const app = getApp() + +Page({ + data: { + statusBarHeight: 44, + navBarHeight: 44, + totalNavHeight: 88, + loading: false, + + // 活动标签 + activeTab: 'featured', // featured: 精选活动, free: 免费活动, vip: VIP活动, svip: SVIP活动 + + // 活动列表 + activityList: [], + + // 二维码弹窗 + showQrcodeModal: false, + qrcodeImageUrl: '' // 单身聚会群二维码 + }, + + onLoad() { + // 计算导航栏高度 + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + const menuButton = wx.getMenuButtonBoundingClientRect() + const navBarHeight = menuButton.height + (menuButton.top - statusBarHeight) * 2 + const totalNavHeight = statusBarHeight + navBarHeight + + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight + }) + + this.loadActivityList() + }, + + /** + * 返回上一页 + */ + onBack() { + wx.navigateBack() + }, + + /** + * 切换活动标签 + */ + onTabChange(e) { + const tab = e.currentTarget.dataset.tab + if (tab === this.data.activeTab) return + + this.setData({ activeTab: tab }) + this.loadActivityList() + }, + + /** + * 加载活动列表 - 根据category筛选单身聚会 + */ + async loadActivityList() { + this.setData({ loading: true }) + + try { + const { activeTab } = this.data + const params = { + category: 'city', // 单身聚会通常属于同城活动 + limit: 100 + } + + // 根据选中的标签添加筛选条件 + if (activeTab === 'featured') { + params.tab = 'featured' + } else if (activeTab === 'free') { + params.priceType = 'free' + } else if (activeTab === 'vip') { + params.is_vip = true + } else if (activeTab === 'svip') { + params.is_svip = true + } + + const res = await api.activity.getList(params) + + if (res.success && res.data && res.data.list) { + // 前端筛选:显示categoryName包含"单身"或"聚会"的活动 + const allActivities = res.data.list + const filteredActivities = allActivities.filter(item => + item.categoryName === '单身聚会' || + (item.title && (item.title.includes('单身') || item.title.includes('聚会'))) + ) + + // 获取第一个活动的二维码作为俱乐部二维码(如果有) + let clubQrcode = '' + const firstWithQrcode = filteredActivities.find(item => item.activityGuideQrcode || item.activity_guide_qrcode) + if (firstWithQrcode) { + clubQrcode = firstWithQrcode.activityGuideQrcode || firstWithQrcode.activity_guide_qrcode + } + + // 转换数据格式 + const activityList = filteredActivities.map(item => { + const heat = item.heat || (item.likes * 2 + (item.views || 0) + (item.current_participants || 0) * 3) + + return { + id: item.id, + title: item.title, + date: this.formatDate(item.start_date || item.activityDate), + location: item.location || '', + venue: item.venue || '', + image: item.coverImage || item.cover_image || '', + heat: Math.floor(heat), + price: item.price_text || item.priceText || '免费', + priceType: item.is_free || item.priceType === 'free' ? 'free' : 'paid', + likes: item.likes || item.likesCount || 0, + participants: item.current_participants || item.currentParticipants || 0, + isLiked: item.is_liked || item.isLiked || false, + isSignedUp: item.is_registered || item.isSignedUp || false, + status: item.status || (item.currentParticipants >= item.maxParticipants && item.maxParticipants > 0 ? 'full' : 'upcoming'), + activityGuideQrcode: item.activityGuideQrcode || item.activity_guide_qrcode || '' + } + }) + + console.log('[singles-party] 加载成功,数量:', activityList.length) + this.setData({ + activityList, + qrcodeImageUrl: clubQrcode || this.data.qrcodeImageUrl || 'https://ai-c.maimanji.com/images/qrcode-singles-party.jpg' + }) + } else { + this.setData({ activityList: [] }) + } + } catch (err) { + console.error('加载活动列表失败', err) + wx.showToast({ + title: '加载失败', + icon: 'none' + }) + this.setData({ activityList: [] }) + } finally { + this.setData({ loading: false }) + } + }, + + /** + * 格式化日期 + */ + formatDate(dateStr) { + if (!dateStr) return '' + const date = new Date(dateStr) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}年${month}月${day}日` + }, + + /** + * 点击活动卡片 + */ + onActivityTap(e) { + const id = e.currentTarget.dataset.id + wx.navigateTo({ + url: `/pages/activity-detail/activity-detail?id=${id}` + }) + }, + + /** + * 点赞 + */ + async onLike(e) { + const id = e.currentTarget.dataset.id + const index = e.currentTarget.dataset.index + + if (!app.globalData.isLoggedIn) { + wx.navigateTo({ url: '/pages/login/login' }) + return + } + + try { + const res = await api.activity.toggleLike(id) + if (res.success) { + this.setData({ + [`activityList[${index}].isLiked`]: res.data.isLiked, + [`activityList[${index}].likes`]: res.data.likesCount + }) + } + } catch (err) { + console.error('点赞失败', err) + wx.showToast({ title: '操作失败', icon: 'none' }) + } + }, + + /** + * 立即报名 + */ + async onSignUp(e) { + const id = e.currentTarget.dataset.id + const index = e.currentTarget.dataset.index + + if (!app.globalData.isLoggedIn) { + wx.navigateTo({ url: '/pages/login/login' }) + return + } + + const activity = this.data.activityList[index] + + // 检查活动状态 + if (activity.status === 'full' || activity.status === 'ended') { + const qrCode = activity.activityGuideQrcode || activity.activity_guide_qrcode || this.data.qrcodeImageUrl || 'https://ai-c.maimanji.com/api/common/qrcode?type=group' + this.setData({ + qrcodeImageUrl: qrCode, + showQrcodeModal: true + }) + return + } + + try { + if (activity.isSignedUp) { + // 取消报名 + const res = await api.activity.cancelSignup(id) + if (res.success) { + wx.showToast({ title: '已取消报名', icon: 'success' }) + this.loadActivityList() + } + } else { + // 报名 + const res = await api.activity.signup(id) + if (res.success) { + wx.showToast({ title: '报名成功', icon: 'success' }) + this.loadActivityList() + } else { + // 检查是否需要显示二维码 + if (res.code === 'QR_CODE_REQUIRED' || res.error === 'QR_CODE_REQUIRED' || res.code === 'ACTIVITY_ENDED' || res.error === '活动已结束') { + if (activity.activityGuideQrcode || activity.activity_guide_qrcode) { + this.setData({ qrcodeImageUrl: activity.activityGuideQrcode || activity.activity_guide_qrcode }) + } + this.setData({ showQrcodeModal: true }) + if (res.code === 'ACTIVITY_ENDED' || res.error === '活动已结束') { + wx.showToast({ title: '活动已结束,进群查看更多', icon: 'none' }) + } + } else { + wx.showToast({ + title: res.error || '操作失败', + icon: 'none' + }) + } + } + } + } catch (err) { + console.error('报名操作失败', err) + const isQrRequired = err && (err.code === 'QR_CODE_REQUIRED' || (err.data && err.data.code === 'QR_CODE_REQUIRED')) + const isActivityEnded = err && (err.code === 'ACTIVITY_ENDED' || (err.data && err.data.code === 'ACTIVITY_ENDED') || err.error === '活动已结束') + + if (isQrRequired || isActivityEnded) { + if (activity.activityGuideQrcode || activity.activity_guide_qrcode) { + this.setData({ qrcodeImageUrl: activity.activityGuideQrcode || activity.activity_guide_qrcode }) + } + this.setData({ showQrcodeModal: true }) + if (isActivityEnded) { + wx.showToast({ title: '活动已结束,进群查看更多', icon: 'none' }) + } + } else { + wx.showToast({ + title: err.error || err.message || '操作失败', + icon: 'none' + }) + } + } + }, + + /** + * 加入单身聚会群 + */ + onJoinGroup() { + if (!this.data.qrcodeImageUrl && this.data.activityList.length > 0) { + const firstWithQrcode = this.data.activityList.find(item => item.activityGuideQrcode || item.activity_guide_qrcode) + if (firstWithQrcode) { + this.setData({ qrcodeImageUrl: firstWithQrcode.activityGuideQrcode || firstWithQrcode.activity_guide_qrcode }) + } + } + this.setData({ showQrcodeModal: true }) + }, + + /** + * 关闭二维码弹窗 + */ + onCloseQrcodeModal() { + this.setData({ showQrcodeModal: false }) + }, + + /** + * 阻止冒泡 + */ + preventBubble() { + return + }, + + /** + * 保存二维码 + */ + async onSaveQrcode() { + try { + const { qrcodeImageUrl } = this.data + const downloadRes = await new Promise((resolve, reject) => { + wx.downloadFile({ + url: qrcodeImageUrl, + success: resolve, + fail: reject + }) + }) + + if (downloadRes.statusCode !== 200) throw new Error('下载失败') + + await new Promise((resolve, reject) => { + wx.saveImageToPhotosAlbum({ + filePath: downloadRes.tempFilePath, + success: resolve, + fail: reject + }) + }) + + wx.showToast({ title: '保存成功', icon: 'success' }) + this.onCloseQrcodeModal() + } catch (err) { + console.error('保存失败', err) + wx.showToast({ title: '保存失败', icon: 'none' }) + } + } +}) \ No newline at end of file diff --git a/pages/singles-party/singles-party.json b/pages/singles-party/singles-party.json new file mode 100644 index 0000000..b3c3b9f --- /dev/null +++ b/pages/singles-party/singles-party.json @@ -0,0 +1,7 @@ +{ + "navigationStyle": "custom", + "navigationBarTextStyle": "black", + "usingComponents": { + "app-icon": "../../components/icon/icon" + } +} \ No newline at end of file diff --git a/pages/singles-party/singles-party.wxml b/pages/singles-party/singles-party.wxml new file mode 100644 index 0000000..ad256cb --- /dev/null +++ b/pages/singles-party/singles-party.wxml @@ -0,0 +1,135 @@ + + + + + + + + + + 单身聚会 + + + + + + + + + 单身聚会俱乐部 + + 遇见缘分 + 告别单身 + + + + 点击立即加入 + + + + + + + + + 精选活动 + + + 免费活动 + + + VIP活动 + + + SVIP活动 + + + + + + + + + + + + + + {{item.price}} + + + + {{item.title}} + + + + {{item.date}} + + + + + {{item.location}} · {{item.venue}} + + + + {{item.heat}} + + + + + + + + {{item.participants}}人已报名 + + + + + + + + + 暂无活动 + + + + 没有更多活动了 ~ + + + + + + + + + + + + + 加入单身聚会群 + 遇见对的人,开启幸福人生 + + + + 长按二维码识别或保存 + 保存二维码 + + + \ No newline at end of file diff --git a/pages/singles-party/singles-party.wxss b/pages/singles-party/singles-party.wxss new file mode 100644 index 0000000..31f5557 --- /dev/null +++ b/pages/singles-party/singles-party.wxss @@ -0,0 +1,557 @@ +/* 单身聚会页面样式 - 浪漫粉色主题 */ +page { + background: linear-gradient(180deg, #FCE4EC 0%, #F8BBD0 100%); +} + +.page-container { + min-height: 100vh; + background: linear-gradient(180deg, #FCE4EC 0%, #F8BBD0 100%); +} + +/* 固定导航栏容器 */ +.nav-container { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 999; + background: rgba(255, 252, 253, 0.75); + backdrop-filter: blur(20rpx) saturate(180%); + -webkit-backdrop-filter: blur(20rpx) saturate(180%); +} + +/* 状态栏 */ +.status-bar { + background: transparent; +} + +/* 导航栏 */ +.nav-bar { + display: flex; + align-items: center; + justify-content: center; + padding: 0 32rpx; + background: transparent; + position: relative; +} + +.nav-back { + position: absolute; + left: 32rpx; + top: 50%; + transform: translateY(-50%); + width: 80rpx; + height: 80rpx; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +.back-icon { + width: 48rpx; + height: 48rpx; +} + +.nav-title { + font-size: 36rpx; + font-weight: 700; + color: #1A1A1A; + line-height: 1; +} + +/* 内容滚动区域 */ +.content-scroll { + height: 100vh; + box-sizing: border-box; +} + +/* 推广卡片 - 浪漫粉色渐变 */ +.city-group-card { + margin: 32rpx; + padding: 32rpx 40rpx; + min-height: 128rpx; + background: linear-gradient(135deg, + rgba(252, 228, 236, 0.6) 0%, + rgba(248, 187, 208, 0.6) 100%); + backdrop-filter: blur(16rpx) saturate(150%); + border: 2rpx solid rgba(236, 64, 122, 0.3); + border-radius: 48rpx; + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: 0 4rpx 20rpx rgba(236, 64, 122, 0.12); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.city-group-card:active { + transform: scale(0.98); + box-shadow: 0 2rpx 12rpx rgba(236, 64, 122, 0.18); +} + +.group-info { + flex: 1; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8rpx; + padding-right: 24rpx; +} + +.group-title { + font-size: 40rpx; + font-weight: 700; + color: #1A1A1A; + line-height: 1.4; + white-space: nowrap; +} + +.group-tags { + display: flex; + flex-direction: column; + gap: 4rpx; +} + +.tag-item { + font-size: 28rpx; + font-weight: 500; + color: #4A5565; + line-height: 1.4; + white-space: nowrap; +} + +.join-btn { + padding: 0 40rpx; + height: 88rpx; + background: linear-gradient(135deg, #EC407A 0%, #D81B60 100%); + border-radius: 100rpx; + display: flex; + align-items: center; + justify-content: center; + font-size: 32rpx; + font-weight: 700; + color: #fff; + white-space: nowrap; + flex-shrink: 0; + box-shadow: 0 6rpx 24rpx rgba(236, 64, 122, 0.4), + 0 3rpx 12rpx rgba(236, 64, 122, 0.3); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; +} + +.join-btn:active { + transform: scale(0.96); + box-shadow: 0 4rpx 16rpx rgba(236, 64, 122, 0.45); +} + +/* 活动标签切换 - 横向滚动 */ +.tab-section { + padding: 32rpx 0; + background: transparent; + margin: 0 32rpx 32rpx; + position: relative; + z-index: 1; +} + +.tab-scroll { + width: 100%; + white-space: nowrap; +} + +.tab-scroll::-webkit-scrollbar { + display: none; +} + +.tab-list { + display: inline-flex; + gap: 20rpx; + padding: 0 4rpx; +} + +.tab-item { + padding: 20rpx 48rpx; + border-radius: 100rpx; + font-size: 32rpx; + font-weight: 700; + color: #6A7282; + background: rgba(255, 255, 255, 0.6); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; + flex-shrink: 0; + white-space: nowrap; +} + +.tab-item:active { + transform: scale(0.96); +} + +.tab-item.active { + color: #fff; + background: linear-gradient(135deg, #EC407A 0%, #D81B60 100%); + box-shadow: 0 12rpx 24rpx rgba(236, 64, 122, 0.3); + transform: scale(1.02); +} + +/* 活动列表 - 毛玻璃卡片 */ +.activity-list { + padding: 0 32rpx; +} + +.activity-card { + margin-bottom: 32rpx; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(16rpx); + border-radius: 32rpx; + overflow: hidden; + box-shadow: 0 8rpx 32rpx rgba(236, 64, 122, 0.12), + 0 4rpx 16rpx rgba(236, 64, 122, 0.08); + border: 1rpx solid rgba(236, 64, 122, 0.15); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.activity-card:active { + transform: scale(0.98); + box-shadow: 0 4rpx 16rpx rgba(236, 64, 122, 0.15); +} + +/* 活动图片容器 */ +.activity-image-wrap { + position: relative; + width: 100%; + height: 360rpx; + overflow: hidden; + background: linear-gradient(135deg, #FCE4EC 0%, #F8BBD0 100%); +} + +.activity-image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.activity-image-gradient { + width: 100%; + height: 100%; + background: linear-gradient(135deg, #FCE4EC 0%, #F8BBD0 100%); +} + +/* 点赞徽章 */ +.like-badge { + position: absolute; + top: 24rpx; + right: 24rpx; + display: flex; + align-items: center; + gap: 8rpx; + padding: 10rpx 20rpx; + background: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(10rpx); + border-radius: 100rpx; + z-index: 10; + box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1); +} + +.like-count { + font-size: 24rpx; + color: #4A5565; + font-weight: 600; +} + +.like-badge.liked .like-count { + color: #FF5252; +} + +/* 价格标签 */ +.price-tag { + position: absolute; + bottom: 24rpx; + left: 24rpx; + padding: 10rpx 24rpx; + border-radius: 12rpx; + font-size: 24rpx; + font-weight: 700; + color: #FFFFFF; + z-index: 10; + box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.15); +} + +.price-tag.free { + background: #4CAF50; +} + +.price-tag.paid { + background: #FF9800; +} + +/* 活动信息 */ +.activity-info { + padding: 40rpx; +} + +.activity-title { + font-size: 36rpx; + font-weight: 700; + color: #D81B60; + margin-bottom: 20rpx; + line-height: 1.4; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; +} + +.activity-meta { + display: flex; + align-items: center; + gap: 32rpx; + margin-bottom: 24rpx; +} + +.meta-item { + display: flex; + align-items: center; + gap: 8rpx; + font-size: 26rpx; + color: #EC407A; +} + +.meta-icon { + width: 28rpx; + height: 28rpx; +} + +.meta-text { + font-size: 26rpx; + color: #4A5565; +} + +.meta-row { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + margin-top: 8rpx; +} + +/* 活动底部 */ +.activity-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 24rpx; + border-top: 1rpx solid rgba(236, 64, 122, 0.1); +} + +.participants { + display: flex; + align-items: center; + gap: 12rpx; +} + +.avatar-stack { + display: flex; + align-items: center; +} + +.mini-avatar { + width: 48rpx; + height: 48rpx; + border-radius: 50%; + background: #FCE4EC; + border: 2rpx solid #fff; + margin-left: -12rpx; +} + +.mini-avatar:first-child { + margin-left: 0; +} + +.participant-text { + font-size: 24rpx; + color: #62748E; +} + +.heat-item { + margin-left: auto; +} + +.heat-text { + color: #FF9800; + font-weight: 600; +} + +/* 立即报名按钮 */ +.signup-btn { + width: 220rpx; + height: 72rpx; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #EC407A 0%, #D81B60 100%); + border-radius: 100rpx; + font-size: 28rpx; + font-weight: 700; + color: #FFFFFF; + box-shadow: 0 6rpx 20rpx rgba(236, 64, 122, 0.3); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + white-space: nowrap; + flex-shrink: 0; +} + +.signup-btn.signed { + background: #9CA3AF; + box-shadow: none; +} + +.signup-btn:active { + transform: scale(0.95); + box-shadow: 0 4rpx 12rpx rgba(236, 64, 122, 0.35); +} + +/* 空状态 */ +.empty-state { + padding: 120rpx 32rpx; + text-align: center; +} + +.empty-icon { + width: 200rpx; + height: 200rpx; + margin: 0 auto 32rpx; + opacity: 0.5; +} + +.empty-text { + font-size: 28rpx; + color: #F06292; +} + +/* 列表底部 */ +.list-footer { + padding: 40rpx 0; + text-align: center; +} + +.footer-text { + font-size: 24rpx; + color: #99A1AF; +} + +/* 二维码弹窗 */ +.qrcode-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 9999; + display: none; + align-items: center; + justify-content: center; +} + +.qrcode-modal.show { + display: flex; +} + +.modal-mask { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4rpx); +} + +.modal-content { + position: relative; + width: 680rpx; + background: #FFFFFF; + border-radius: 64rpx; + padding: 64rpx; + box-shadow: 0 50rpx 100rpx -24rpx rgba(0, 0, 0, 0.25); + display: flex; + flex-direction: column; + align-items: center; + z-index: 1; +} + +.close-btn { + position: absolute; + top: 32rpx; + right: 32rpx; + width: 72rpx; + height: 72rpx; + background: #F1F5F9; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.close-icon { + width: 40rpx; + height: 40rpx; +} + +.modal-title { + font-size: 48rpx; + font-weight: 700; + color: #1D293D; + text-align: center; + margin-bottom: 16rpx; + line-height: 1.5; +} + +.modal-subtitle { + font-size: 32rpx; + color: #62748E; + text-align: center; + margin-bottom: 48rpx; + line-height: 1.5; +} + +.qrcode-container { + width: 440rpx; + height: 440rpx; + background: #F8FAFC; + border: 2rpx solid #F1F5F9; + border-radius: 40rpx; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 48rpx; + overflow: hidden; +} + +.qrcode-image { + width: 404rpx; + height: 404rpx; + border-radius: 24rpx; +} + +.modal-tips { + font-size: 24rpx; + color: #999; + margin-bottom: 24rpx; +} + +.save-btn { + width: 552rpx; + height: 116rpx; + background: #EC407A; + border-radius: 100rpx; + display: flex; + align-items: center; + justify-content: center; + font-size: 40rpx; + font-weight: 700; + color: #FFFFFF; + box-shadow: 0 20rpx 30rpx -6rpx rgba(252, 228, 236, 1); + transition: all 0.3s ease; +} + +.save-btn:active { + transform: scale(0.96); +} \ No newline at end of file diff --git a/pages/square/square.js b/pages/square/square.js new file mode 100644 index 0000000..f70a3b0 --- /dev/null +++ b/pages/square/square.js @@ -0,0 +1,287 @@ +// pages/square/square.js - 微信朋友圈风格 +const app = getApp() +const api = require('../../utils/api') + +// 模拟动态数据 - 将从API获取,这里仅作为备用 +const MOCK_POSTS = [] + +Page({ + data: { + statusBarHeight: 44, + navBarHeight: 44, + totalNavHeight: 88, + posts: [], + showCommentInput: false, + commentText: '', + currentCommentPostId: null, + showCreateModal: false, + newPostText: '', + newPostImages: [], + auditStatus: 0 + }, + + onShow() { + wx.hideTabBar({ animation: false }) + const app = getApp() + this.setData({ + auditStatus: app.globalData.auditStatus + }) + }, + + onLoad() { + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + const menuButton = wx.getMenuButtonBoundingClientRect() + const navBarHeight = menuButton.height + (menuButton.top - statusBarHeight) * 2 + const totalNavHeight = statusBarHeight + navBarHeight + + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight + }) + + // 从API加载动态列表 + this.loadPosts() + }, + + /** + * 加载动态列表 + */ + async loadPosts() { + try { + const res = await api.post.getList({ page: 1, limit: 20 }) + + if (res.success && res.data) { + const posts = (res.data.list || res.data || []).map(post => ({ + id: post.id, + userId: post.user_id, + userName: post.user_name || '用户', + userAvatar: post.user_avatar || '', + content: post.content || '', + images: post.images || [], + time: post.created_at || '', + likes: post.likes || [], + comments: post.comments || [], + isLiked: post.is_liked || false, + likesText: (post.likes || []).join(','), + showActions: false + })) + + this.setData({ posts }) + } + } catch (err) { + console.error('加载动态失败', err) + // 如果API失败,使用空列表 + this.setData({ posts: [] }) + } + }, + + // 显示/隐藏操作面板 + onShowActions(e) { + const postId = e.currentTarget.dataset.id + const posts = this.data.posts.map(post => ({ + ...post, + showActions: post.id === postId ? !post.showActions : false + })) + this.setData({ posts }) + }, + + // 点击页面其他地方隐藏操作面板 + hideAllActions() { + const posts = this.data.posts.map(post => ({ + ...post, + showActions: false + })) + this.setData({ posts }) + }, + + onLikePost(e) { + const postId = e.currentTarget.dataset.id + const posts = this.data.posts.map(post => { + if (post.id === postId) { + const isLiked = !post.isLiked + let likes = [...post.likes] + + if (isLiked) { + likes.push('我') + } else { + likes = likes.filter(l => l !== '我') + } + + return { + ...post, + isLiked, + likes, + likesText: likes.join(','), + showActions: false + } + } + return { ...post, showActions: false } + }) + + this.setData({ posts }) + }, + + onCommentPost(e) { + const postId = e.currentTarget.dataset.id + // 先隐藏操作面板 + const posts = this.data.posts.map(post => ({ + ...post, + showActions: false + })) + + this.setData({ + posts, + showCommentInput: true, + currentCommentPostId: postId, + commentText: '' + }) + }, + + hideCommentInput() { + this.setData({ + showCommentInput: false, + currentCommentPostId: null + }) + }, + + onCommentInput(e) { + this.setData({ commentText: e.detail.value }) + }, + + onSendComment() { + const { commentText, currentCommentPostId, posts } = this.data + + if (!commentText.trim()) { + wx.showToast({ title: '请输入评论内容', icon: 'none' }) + return + } + + const newPosts = posts.map(post => { + if (post.id === currentCommentPostId) { + return { + ...post, + comments: [ + ...post.comments, + { id: 'c' + Date.now(), user: '我', text: commentText } + ] + } + } + return post + }) + + this.setData({ + posts: newPosts, + showCommentInput: false, + commentText: '', + currentCommentPostId: null + }) + + wx.showToast({ title: '评论成功', icon: 'success' }) + }, + + onPreviewImage(e) { + const { urls, current } = e.currentTarget.dataset + wx.previewImage({ + urls, + current + }) + }, + + onCreatePost() { + this.setData({ showCreateModal: true }) + }, + + hideCreateModal() { + this.setData({ + showCreateModal: false, + newPostText: '', + newPostImages: [] + }) + }, + + onNewPostInput(e) { + this.setData({ newPostText: e.detail.value }) + }, + + onAddImage() { + wx.chooseMedia({ + count: 9 - this.data.newPostImages.length, + mediaType: ['image'], + sourceType: ['album', 'camera'], + success: (res) => { + const newImages = res.tempFiles.map(f => f.tempFilePath) + this.setData({ + newPostImages: [...this.data.newPostImages, ...newImages] + }) + } + }) + }, + + onDeleteImage(e) { + const index = e.currentTarget.dataset.index + const images = this.data.newPostImages.filter((_, i) => i !== index) + this.setData({ newPostImages: images }) + }, + + async onSubmitPost() { + const { newPostText, newPostImages } = this.data + + if (!newPostText.trim() && newPostImages.length === 0) { + wx.showToast({ title: '请输入内容或添加图片', icon: 'none' }) + return + } + + try { + // 调用API发布动态 + const res = await api.post.create({ + content: newPostText, + images: newPostImages + }) + + if (res.success) { + this.setData({ + showCreateModal: false, + newPostText: '', + newPostImages: [] + }) + + wx.showToast({ title: '发布成功', icon: 'success' }) + + // 重新加载动态列表 + this.loadPosts() + } else { + wx.showToast({ title: res.message || '发布失败', icon: 'none' }) + } + } catch (err) { + console.error('发布动态失败', err) + wx.showToast({ title: '发布失败', icon: 'none' }) + } + }, + + // 切换 Tab - 需要登录的页面检查登录状态 + switchTab(e) { + const path = e.currentTarget.dataset.path + const app = getApp() + + // 消息和我的页面需要登录 + if (path === '/pages/chat/chat' || path === '/pages/profile/profile') { + if (!app.globalData.isLoggedIn) { + wx.navigateTo({ + url: '/pages/login/login?redirect=' + encodeURIComponent(path) + }) + return + } + } + wx.switchTab({ url: path }) + }, + + // 测试按钮 + onTest() { + wx.showToast({ + title: '测试功能', + icon: 'none' + }) + } +}) diff --git a/pages/square/square.json b/pages/square/square.json new file mode 100644 index 0000000..b16ef3e --- /dev/null +++ b/pages/square/square.json @@ -0,0 +1,4 @@ +{ + "navigationStyle": "custom", + "usingComponents": {} +} diff --git a/pages/square/square.wxml b/pages/square/square.wxml new file mode 100644 index 0000000..4c0cba7 --- /dev/null +++ b/pages/square/square.wxml @@ -0,0 +1,191 @@ + + + + + + + + 广场 + + + + + + + + + + + + + + + + + + + + {{item.userName}} + + + + {{item.content}} + + + + + + + + + + + + + + + {{item.isLiked ? '取消' : '赞'}} + + + + + 评论 + + + + + + + + + + + + {{comment.user}} + + 回复 + {{comment.replyTo}} + + + {{comment.text}} + + + + + + + + + + + + + + + + + 取消 + 写评论 + 发送 + + + + + + + + + + + {{commentText.length || 0}}/500 + + + + + + + + + + 取消 + 发表动态 + 发表 + + + + + + × + + + + + + + + 添加图片 + + + + + + + + + 文娱 + + + + 陪聊 + + + + + + 聊天 + + + + 广场 + + + + 我的 + + + diff --git a/pages/square/square.wxss b/pages/square/square.wxss new file mode 100644 index 0000000..2361a4a --- /dev/null +++ b/pages/square/square.wxss @@ -0,0 +1,610 @@ +/* 广场页面样式 - 微信朋友圈风格 */ +.page-container { + min-height: 100vh; + background: #fff; +} + +/* 自定义导航栏 */ +.custom-nav { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + background: #ededed; + border-bottom: 1rpx solid #d9d9d9; +} + +.nav-bar { + height: 88rpx; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 32rpx; +} + +.nav-left, .nav-right { + width: 80rpx; +} + +.nav-title { + font-size: 34rpx; + font-weight: 600; + color: #000; +} + +.nav-right { + display: flex; + justify-content: flex-end; +} + +.create-btn { + width: 56rpx; + height: 56rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.create-icon { + width: 48rpx; + height: 48rpx; +} + +/* 内容滚动区域 */ +.content-scroll { + height: 100vh; + background: #fff; +} + +/* 动态列表 */ +.post-list { + padding-bottom: 220rpx; +} + +.post-item { + display: flex; + padding: 32rpx 32rpx 0; + border-bottom: 1rpx solid #ededed; +} + +/* 头像 */ +.post-avatar { + width: 84rpx; + height: 84rpx; + border-radius: 8rpx; + flex-shrink: 0; + margin-right: 20rpx; +} + +/* 右侧内容区 */ +.post-main { + flex: 1; + min-width: 0; + padding-bottom: 24rpx; +} + +/* 用户名 - 微信蓝色链接样式 */ +.post-username { + font-size: 32rpx; + font-weight: 600; + color: #576b95; + line-height: 1.4; + display: block; + margin-bottom: 8rpx; +} + +/* 内容文字 */ +.post-content { + margin-bottom: 16rpx; +} + +.post-text { + font-size: 30rpx; + color: #111; + line-height: 1.5; + word-break: break-all; +} + +/* 图片区域 - 九宫格布局 */ +.post-images { + display: flex; + flex-wrap: wrap; + gap: 8rpx; + margin-bottom: 16rpx; + max-width: 480rpx; +} + +.post-image { + border-radius: 6rpx; + background: #f5f5f5; +} + +/* 单张图片 */ +.post-images.images-1 .post-image { + width: 360rpx; + height: 360rpx; +} + +/* 两张图片 */ +.post-images.images-2 .post-image { + width: 236rpx; + height: 236rpx; +} + +/* 三张图片 */ +.post-images.images-3 .post-image { + width: 156rpx; + height: 156rpx; +} + +/* 四张图片 - 2x2 */ +.post-images.images-4 .post-image { + width: 236rpx; + height: 236rpx; +} + +/* 五张及以上 - 3列 */ +.post-images.images-5 .post-image, +.post-images.images-6 .post-image, +.post-images.images-7 .post-image, +.post-images.images-8 .post-image, +.post-images.images-9 .post-image { + width: 156rpx; + height: 156rpx; +} + +/* 底部信息栏 */ +.post-footer { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12rpx; +} + +.post-time { + font-size: 24rpx; + color: #b2b2b2; +} + +/* 操作按钮 - 两个小点 */ +.post-actions-btn { + padding: 12rpx 16rpx; + background: #f7f7f7; + border-radius: 6rpx; +} + +.action-dots { + display: flex; + gap: 6rpx; +} + +.dot { + width: 10rpx; + height: 10rpx; + background: #576b95; + border-radius: 50%; +} + +/* 操作面板 */ +.action-panel { + display: none; + background: #4c4c4c; + border-radius: 8rpx; + margin-bottom: 12rpx; + overflow: hidden; + flex-direction: row; +} + +.action-panel.show { + display: flex; +} + +.action-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 8rpx; + padding: 16rpx 24rpx; +} + +.action-btn-icon { + width: 32rpx; + height: 32rpx; + filter: brightness(0) invert(1); +} + +.action-btn-text { + font-size: 26rpx; + color: #fff; +} + +.action-divider { + width: 1rpx; + background: #666; + margin: 8rpx 0; +} + +/* 点赞和评论区域 */ +.post-interact { + background: #f7f7f7; + border-radius: 6rpx; + overflow: hidden; +} + +/* 点赞区域 */ +.likes-section { + display: flex; + align-items: flex-start; + padding: 12rpx 16rpx; + gap: 8rpx; +} + +.likes-icon { + width: 28rpx; + height: 28rpx; + flex-shrink: 0; + margin-top: 4rpx; +} + +.likes-text { + font-size: 28rpx; + color: #576b95; + line-height: 1.4; + word-break: break-all; +} + +/* 评论区域 */ +.comments-section { + padding: 0 16rpx 12rpx; + border-top: 1rpx solid #e5e5e5; +} + +.likes-section + .comments-section { + border-top: 1rpx solid #e5e5e5; +} + +.post-interact > .comments-section:first-child { + border-top: none; + padding-top: 12rpx; +} + +.comment-item { + padding: 6rpx 0; + font-size: 28rpx; + line-height: 1.5; +} + +.comment-user { + color: #576b95; +} + +.comment-reply { + color: #111; + margin: 0 4rpx; +} + +.comment-colon { + color: #111; +} + +.comment-text { + color: #111; + word-break: break-all; +} + +/* 底部占位 */ +.bottom-placeholder { + height: 48rpx; +} + +/* 评论弹窗 */ +.comment-modal { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 64rpx; +} + +.comment-modal-mask { + position: absolute; + inset: 0; + background: rgba(0,0,0,0.6); +} + +.comment-modal-content { + position: relative; + width: 100%; + max-width: 620rpx; + background: #fff; + border-radius: 24rpx; + overflow: hidden; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: scale(0.9); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.comment-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 28rpx 32rpx; + border-bottom: 1rpx solid #e5e5e5; +} + +.comment-modal-cancel { + font-size: 32rpx; + color: #666; + padding: 8rpx 16rpx; +} + +.comment-modal-title { + font-size: 34rpx; + font-weight: 600; + color: #111; +} + +.comment-modal-send { + font-size: 32rpx; + color: #b2b2b2; + padding: 8rpx 16rpx; +} + +.comment-modal-send.active { + color: #576b95; + font-weight: 600; +} + +.comment-modal-body { + padding: 24rpx 32rpx; + min-height: 240rpx; +} + +.comment-textarea { + width: 100%; + min-height: 200rpx; + font-size: 32rpx; + line-height: 1.6; + color: #111; +} + +.comment-textarea::placeholder { + color: #b2b2b2; +} + +.comment-modal-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16rpx 32rpx 24rpx; + border-top: 1rpx solid #f0f0f0; +} + +.comment-toolbar { + display: flex; + gap: 24rpx; +} + +.toolbar-btn { + padding: 8rpx; +} + +.toolbar-icon { + width: 48rpx; + height: 48rpx; + opacity: 0.6; +} + +.comment-count { + font-size: 24rpx; + color: #b2b2b2; +} + +/* 发布动态弹窗 */ +.create-modal { + position: fixed; + inset: 0; + z-index: 300; +} + +.create-modal-mask { + position: absolute; + inset: 0; + background: rgba(0,0,0,0.5); +} + +.create-modal-content { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: #fff; + border-radius: 24rpx 24rpx 0 0; + padding-bottom: env(safe-area-inset-bottom); + max-height: 80vh; + overflow-y: auto; +} + +.create-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 28rpx 32rpx; + border-bottom: 1rpx solid #e5e5e5; +} + +.create-cancel { + font-size: 32rpx; + color: #111; +} + +.create-title { + font-size: 34rpx; + font-weight: 600; + color: #111; +} + +.create-submit { + font-size: 32rpx; + color: #b2b2b2; +} + +.create-submit.active { + color: #576b95; + font-weight: 600; +} + +.create-textarea { + width: 100%; + min-height: 240rpx; + padding: 24rpx 32rpx; + font-size: 32rpx; + line-height: 1.5; + box-sizing: border-box; +} + +.create-images { + display: flex; + flex-wrap: wrap; + gap: 16rpx; + padding: 0 32rpx 32rpx; +} + +.create-image-item { + position: relative; + width: calc(33.33% - 12rpx); + aspect-ratio: 1; +} + +.create-image { + width: 100%; + height: 100%; + border-radius: 8rpx; +} + +.create-image-delete { + position: absolute; + top: -12rpx; + right: -12rpx; + width: 40rpx; + height: 40rpx; + background: rgba(0,0,0,0.6); + color: #fff; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 28rpx; +} + +.create-image-add { + width: calc(33.33% - 12rpx); + aspect-ratio: 1; + background: #f7f7f7; + border-radius: 8rpx; + display: flex; + align-items: center; + justify-content: center; + border: 1rpx dashed #d9d9d9; +} + +.create-image-add-empty { + margin: 0 32rpx 32rpx; + padding: 48rpx; + background: #f7f7f7; + border-radius: 8rpx; + display: flex; + flex-direction: column; + align-items: center; + gap: 16rpx; + border: 1rpx dashed #d9d9d9; +} + +.add-icon { + width: 56rpx; + height: 56rpx; + opacity: 0.4; +} + +.add-text { + font-size: 28rpx; + color: #b2b2b2; +} + + +/* 自定义底部导航栏 */ +.custom-tabbar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 210rpx; + background: #fff; + border-radius: 60rpx 60rpx 0 0; + box-shadow: 0 -10rpx 30rpx rgba(0,0,0,0.04); + display: flex; + align-items: flex-start; + justify-content: space-around; + padding-top: 24rpx; + z-index: 999; +} + +.tabbar-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 8rpx; + width: 128rpx; +} + +.tabbar-icon { + width: 72rpx; + height: 72rpx; +} + +.tabbar-text { + font-size: 34rpx; + font-weight: 700; + color: #a58aa5; +} + +.tabbar-text.active { + color: #b06ab3; +} + +.tabbar-center { + display: flex; + flex-direction: column; + align-items: center; + gap: 8rpx; + margin-top: -80rpx; +} + +.center-btn { + width: 144rpx; + height: 144rpx; + background: linear-gradient(180deg, #c984cd 0%, #b06ab3 100%); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 0 0 12rpx #faf8fc, 0 16rpx 40rpx rgba(176,106,179,0.4); +} + +.center-icon { + width: 80rpx; + height: 80rpx; +} \ No newline at end of file diff --git a/pages/support/support.js b/pages/support/support.js new file mode 100644 index 0000000..ec1d2f9 --- /dev/null +++ b/pages/support/support.js @@ -0,0 +1,214 @@ +// pages/support/support.js +const app = getApp() +const api = require('../../utils/api') +const util = require('../../utils/util') +const imageUrl = require('../../utils/imageUrl') + +Page({ + data: { + statusBarHeight: 44, + navHeight: 96, + myAvatar: '/images/default-avatar.svg', + messages: [], + inputText: '', + inputFocus: false, + isTyping: false, + ticketId: '', + scrollIntoView: '', + scrollTop: 0, + pollingTimer: null + }, + + onLoad() { + const { statusBarHeight, navHeight, userInfo } = app.globalData + const myAvatar = imageUrl.getAvatarUrl(userInfo?.avatar) + + this.setData({ + statusBarHeight, + navHeight, + myAvatar + }) + + this.initSupport() + }, + + onAvatarError() { + this.setData({ + myAvatar: '/images/default-avatar.svg' + }) + }, + + onUnload() { + this.stopPolling() + }, + + onHide() { + this.stopPolling() + }, + + onShow() { + if (this.data.ticketId) { + this.startPolling() + } + }, + + /** + * 初始化客服会话 + */ + async initSupport() { + wx.showLoading({ title: '加载中...' }) + try { + const guestId = wx.getStorageSync('guestId') || util.generateId() + if (!wx.getStorageSync('guestId')) { + wx.setStorageSync('guestId', guestId) + } + + // 获取已有咨询列表 + const res = await api.customerService.getList(guestId) + const data = res.data || {} + const tickets = data.tickets || [] + + if (tickets.length > 0) { + // 使用最近的一个工单 + const latestTicket = tickets[0] + this.setData({ ticketId: latestTicket.id }) + await this.loadMessages(latestTicket.id) + } else { + // 如果没有工单,可以在首次发送消息时创建 + console.log('[support] No existing tickets found.') + } + } catch (err) { + console.error('[support] initSupport error:', err) + } finally { + wx.hideLoading() + this.startPolling() + } + }, + + /** + * 加载消息历史 + */ + async loadMessages(ticketId) { + try { + const res = await api.customerService.getDetail(ticketId) + if (res.success && res.data) { + const messages = res.data.messages.map(msg => ({ + id: msg.id, + isMe: msg.senderType === 'user', + text: msg.content, + time: util.formatTime(new Date(msg.createdAt), 'HH:mm'), + senderName: msg.senderName + })) + + // 如果有新消息才更新,避免闪烁 + if (JSON.stringify(messages) !== JSON.stringify(this.data.messages)) { + this.setData({ messages }, () => { + this.scrollToBottom() + }) + } + } + } catch (err) { + console.error('[support] loadMessages error:', err) + } + }, + + /** + * 发送消息 + */ + async onSend() { + const content = this.data.inputText.trim() + if (!content || this.isSending) return + + this.isSending = true + const tempId = util.generateId() + const now = new Date() + + // 先在本地显示 + const userMsg = { + id: tempId, + isMe: true, + text: content, + time: util.formatTime(now, 'HH:mm') + } + + this.setData({ + messages: [...this.data.messages, userMsg], + inputText: '', + inputFocus: true + }, () => { + this.scrollToBottom() + }) + + try { + if (this.data.ticketId) { + // 回复已有工单 + await api.customerService.reply({ + ticketId: this.data.ticketId, + content: content, + userName: app.globalData.userInfo?.nickname || '访客' + }) + } else { + // 创建新工单 + const guestId = wx.getStorageSync('guestId') + const res = await api.customerService.create({ + category: 'other', + content: content, + userName: app.globalData.userInfo?.nickname || '访客', + guestId: guestId + }) + if (res.success && res.data) { + this.setData({ ticketId: res.data.ticketId }) + } + } + // 发送后立即拉取一次 + if (this.data.ticketId) { + await this.loadMessages(this.data.ticketId) + } + } catch (err) { + console.error('[support] send message error:', err) + wx.showToast({ title: '发送失败', icon: 'none' }) + } finally { + this.isSending = false + } + }, + + onInput(e) { + this.setData({ inputText: e.detail.value }) + }, + + /** + * 开始轮询 + */ + startPolling() { + this.stopPolling() + this.data.pollingTimer = setInterval(() => { + if (this.data.ticketId) { + this.loadMessages(this.data.ticketId) + } + }, 4000) // 每4秒轮询一次 + }, + + /** + * 停止轮询 + */ + stopPolling() { + if (this.data.pollingTimer) { + clearInterval(this.data.pollingTimer) + this.data.pollingTimer = null + } + }, + + onBack() { + wx.navigateBack() + }, + + onTapChatArea() { + this.setData({ inputFocus: false }) + }, + + scrollToBottom() { + this.setData({ + scrollIntoView: 'chat-bottom-anchor' + }) + } +}) diff --git a/pages/support/support.json b/pages/support/support.json new file mode 100644 index 0000000..d41cf96 --- /dev/null +++ b/pages/support/support.json @@ -0,0 +1,7 @@ +{ + "usingComponents": { + "app-icon": "/components/icon/icon" + }, + "navigationBarTitleText": "在线客服", + "navigationStyle": "custom" +} \ No newline at end of file diff --git a/pages/support/support.wxml b/pages/support/support.wxml new file mode 100644 index 0000000..cc5c1b6 --- /dev/null +++ b/pages/support/support.wxml @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + 返回 + + + + + + + + 在线客服 + + + + + + + + + + + + + + 您正在与人工客服对话,我们将尽快回复 + + + + + + + + + + + + + {{item.text}} + + + {{item.time}} + + + + + + + + + {{item.text}} + + {{item.time}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pages/support/support.wxss b/pages/support/support.wxss new file mode 100644 index 0000000..793e906 --- /dev/null +++ b/pages/support/support.wxss @@ -0,0 +1,310 @@ +/* pages/support/support.wxss */ + +/* 页面容器 */ +.page-container { + min-height: 100vh; + background: #F5F2FD; + display: flex; + flex-direction: column; + position: relative; +} + +/* 聊天区域包装器 */ +.chat-area-wrapper { + position: fixed; + left: 0; + right: 0; + bottom: 120rpx; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* 状态栏区域 */ +.status-bar-area { + position: fixed; + top: 0; + left: 0; + right: 0; + background: rgba(242, 237, 255, 0.6); + z-index: 101; +} + +/* 顶部导航栏 */ +.nav-header { + position: fixed; + left: 0; + right: 0; + background: rgba(255, 255, 255, 0.95); + border-bottom: 2rpx solid #F3F4F6; + z-index: 100; +} + +.nav-content { + display: flex; + align-items: center; + justify-content: space-between; + height: 98rpx; + padding: 0 16rpx; +} + +/* 返回按钮 */ +.nav-back { + display: flex; + align-items: center; + gap: 4rpx; + padding: 16rpx; + min-width: 160rpx; +} + +.back-icon { + width: 56rpx; + height: 56rpx; +} + +.back-text { + font-size: 34rpx; + font-weight: 700; + color: #914584; +} + +/* 中间角色信息 */ +.nav-center { + display: flex; + align-items: center; + gap: 16rpx; +} + +.nav-avatar-wrap { + width: 64rpx; + height: 64rpx; + border-radius: 50%; + overflow: hidden; + border: 2rpx solid #E5E7EB; + display: flex; + align-items: center; + justify-content: center; + background: #F3F4F6; +} + +.nav-avatar { + width: 80%; + height: 80%; +} + +.nav-name { + font-size: 34rpx; + font-weight: 700; + color: #101828; +} + +.online-dot { + width: 16rpx; + height: 16rpx; + background: #00C950; + border-radius: 50%; +} + +.nav-right-placeholder { + min-width: 160rpx; +} + +/* 聊天内容区域 */ +.chat-scroll { + height: 100%; + padding: 0 32rpx; + padding-top: 20rpx; + padding-bottom: 20rpx; + box-sizing: border-box; +} + +.chat-scroll::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} + +.encrypt-hint { + text-align: center; + padding: 32rpx 0 48rpx; +} + +.encrypt-hint text { + font-size: 24rpx; + color: #99A1AF; +} + +.chat-list { + display: flex; + flex-direction: column; + gap: 48rpx; +} + +.chat-item { + display: flex; + gap: 24rpx; + align-items: flex-start; +} + +.chat-item.me { + justify-content: flex-end; +} + +.avatar-wrap { + width: 88rpx; + height: 88rpx; + border-radius: 50%; + overflow: hidden; + flex-shrink: 0; + border: 2rpx solid rgba(255, 255, 255, 0.5); + box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1); + display: flex; + align-items: center; + justify-content: center; + background: #FFFFFF; +} + +.chat-avatar { + width: 100%; + height: 100%; +} + +.chat-item.other .chat-avatar { + width: 70%; + height: 70%; +} + +.message-content { + max-width: 540rpx; + display: flex; + flex-direction: column; + gap: 8rpx; +} + +.message-content.me { + align-items: flex-end; +} + +.chat-bubble { + padding: 24rpx 40rpx; + word-break: break-all; + box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1); +} + +.chat-bubble.other { + background: #FFFFFF; + border-radius: 12rpx 44rpx 44rpx 44rpx; +} + +.chat-bubble.me { + background: #914584; + border-radius: 44rpx 12rpx 44rpx 44rpx; +} + +.chat-text { + font-size: 34rpx; + line-height: 1.625; +} + +.chat-bubble.other .chat-text { + color: #1E2939; +} + +.chat-bubble.me .chat-text { + color: #FFFFFF; +} + +.message-time { + font-size: 22rpx; + color: #99A1AF; + padding: 0 8rpx; +} + +.message-actions { + display: flex; + align-items: center; + gap: 16rpx; +} + +/* 正在输入动画 */ +.chat-bubble.typing { + display: flex; + gap: 12rpx; + padding: 28rpx 40rpx; +} + +.typing-dot { + width: 16rpx; + height: 16rpx; + background: #9CA3AF; + border-radius: 50%; + animation: typing 1.4s infinite ease-in-out; +} + +.typing-dot:nth-child(1) { animation-delay: 0s; } +.typing-dot:nth-child(2) { animation-delay: 0.2s; } +.typing-dot:nth-child(3) { animation-delay: 0.4s; } + +@keyframes typing { + 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; } + 30% { transform: translateY(-12rpx); opacity: 1; } +} + +.chat-bottom-space { + height: 40rpx; +} + +/* 底部输入区域 */ +.bottom-input-area { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: #FFFFFF; + border-top: 2rpx solid #F3F4F6; + box-shadow: 0 -10rpx 40rpx rgba(0, 0, 0, 0.03); + z-index: 100; + padding-bottom: env(safe-area-inset-bottom); +} + +.figma-input-container { + display: flex; + align-items: center; + gap: 16rpx; + padding: 24rpx 32rpx; + padding-bottom: 24rpx; +} + +.figma-input-wrap { + flex: 1; + background: #F9FAFB; + border: 2rpx solid #F3F4F6; + border-radius: 32rpx; + padding: 0 32rpx; + height: 96rpx; + display: flex; + align-items: center; +} + +.figma-text-input { + width: 100%; + height: 100%; + font-size: 34rpx; + color: #101828; +} + +.figma-send-btn { + width: 88rpx; + height: 88rpx; + background: #914584; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.figma-btn-icon { + width: 44rpx; + height: 44rpx; +} diff --git a/pages/team/team.js b/pages/team/team.js new file mode 100644 index 0000000..6925008 --- /dev/null +++ b/pages/team/team.js @@ -0,0 +1,147 @@ +const { request } = require('../../utils_new/request'); +const util = require('../../utils/util'); + +Page({ + data: { + statusBarHeight: 20, + navBarHeight: 44, + totalNavHeight: 64, + defaultAvatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=500&auto=format&fit=crop&q=60', + loading: true, + stats: { + todayReferrals: 0, + totalReferrals: 0, + totalContribution: '0.00' + }, + cardTitle: '守护会员', + list: [] + }, + onLoad() { + const sys = wx.getSystemInfoSync(); + const menu = wx.getMenuButtonBoundingClientRect(); + const statusBarHeight = sys.statusBarHeight || 20; + const navBarHeight = menu.height + (menu.top - statusBarHeight) * 2; + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight: statusBarHeight + navBarHeight + }); + this.load(); + }, + onBack() { + wx.navigateBack({ delta: 1 }); + }, + async load() { + this.setData({ loading: true }); + try { + try { + const statsRes = await request({ url: '/api/commission?action=stats', method: 'GET' }); + const statsBody = statsRes.data || {}; + if (statsBody.success) { + const d = statsBody.data || {}; + this.setData({ + stats: { + todayReferrals: Number(d.todayReferrals || d.today_referrals || 0), + totalReferrals: Number(d.totalReferrals || d.total_referrals || 0), + totalContribution: Number(d.totalContribution || d.total_contribution || 0).toFixed(2) + }, + cardTitle: this.getCardTitle(d.cardType || d.level || 'guardian_card') + }); + } + + const res = await request({ url: '/api/commission?action=referrals&page=1&pageSize=50', method: 'GET' }); + const body = res.data || {}; + + console.log('[团队页面] API响应:', JSON.stringify(body, null, 2)); + + // Flexible data extraction + let rawList = []; + if (Array.isArray(body.data)) { + rawList = body.data; + } else if (body.data && Array.isArray(body.data.list)) { + rawList = body.data.list; + } else if (body.list && Array.isArray(body.list)) { + rawList = body.list; + } + + console.log('[团队页面] rawList:', JSON.stringify(rawList.slice(0, 2), null, 2)); + + const roleMap = { + 'soulmate': '心伴会员', + 'guardian': '守护会员', + 'companion': '陪伴会员', + 'listener': '倾听会员', + 'partner': '城市合伙人' + }; + + const list = rawList.map((x) => { + const user = x.user || {}; + // Map fields robustly + let avatar = x.avatarUrl || x.avatar_url || x.userAvatar || user.avatarUrl || user.avatar_url || ''; + if (avatar) { + avatar = util.getFullImageUrl(avatar); + } + + const name = x.userName || x.nickname || x.nickName || user.nickname || user.nickName || ('用户' + (x.userId || x.id || '')); + const contribution = Number(x.totalContribution || x.total_contribution || x.amount || 0).toFixed(2); + const dateStr = x.boundAt || x.created_at || x.createdAt || Date.now(); + + // Get Member Level - 优先使用API返回的中文等级名称 + const levelText = x.userRoleName || user.userRoleName || roleMap[x.userRole] || roleMap[x.distributorRole] || roleMap[x.role] || (x.isDistributor ? '分销会员' : '普通用户'); + + return { + ...x, + userId: x.userId || x.id, + userAvatar: avatar || this.data.defaultAvatar, + userName: name, + levelText: levelText, + totalContribution: contribution, + boundAtText: this.formatDate(new Date(dateStr)) + }; + }); + + this.setData({ list }); + } catch (err) { + console.log('API failed, using mock data', err); + this.setData({ + stats: { todayReferrals: 2, totalReferrals: 15, totalContribution: '128.50' }, + list: [ + { userId: 1, userName: '小王', userAvatar: '', boundAtText: '2025-01-20 10:30:45', totalContribution: '12.50' }, + { userId: 2, userName: 'Alice', userAvatar: '', boundAtText: '2025-01-18 14:22:30', totalContribution: '30.00' }, + { userId: 3, userName: 'Bob', userAvatar: '', boundAtText: '2025-01-15 09:15:00', totalContribution: '5.00' } + ] + }); + } + } finally { + this.setData({ loading: false }); + } + }, + formatDate(d) { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + const h = String(d.getHours()).padStart(2, '0'); + const min = String(d.getMinutes()).padStart(2, '0'); + const s = String(d.getSeconds()).padStart(2, '0'); + return `${y}-${m}-${day} ${h}:${min}:${s}`; + }, + onAvatarError(e) { + const index = e.currentTarget.dataset.index; + if (index !== undefined) { + const list = this.data.list; + list[index].userAvatar = this.data.defaultAvatar; + this.setData({ list }); + } + }, + getCardTitle(type) { + const map = { + 'guardian_card': '守护会员', + 'companion_card': '陪伴会员', + 'identity_card': '身份会员', + 'vip': 'VIP会员', + 'partner': '城市合伙人' + }; + return map[type] || '守护会员'; + } +}); + diff --git a/pages/team/team.json b/pages/team/team.json new file mode 100644 index 0000000..3153ca5 --- /dev/null +++ b/pages/team/team.json @@ -0,0 +1,5 @@ +{ + "usingComponents": { + "app-icon": "/components/icon/icon" + } +} diff --git a/pages/team/team.wxml b/pages/team/team.wxml new file mode 100644 index 0000000..1df1d8b --- /dev/null +++ b/pages/team/team.wxml @@ -0,0 +1,61 @@ + + + + + + 返回 + + 我的团队 + + + + + + + + + + + {{cardTitle}} + + + + + 直推人数 + {{list.length}} + + + 团队总计 + {{stats.totalReferrals}} + + + + + + + + + 我的直推 + + + + + + 加载中... + 暂无直推成员 + + + + + + {{item.userName}} + {{item.levelText}} + + {{item.boundAtText}} + + + + + + + diff --git a/pages/team/team.wxss b/pages/team/team.wxss new file mode 100644 index 0000000..e755b47 --- /dev/null +++ b/pages/team/team.wxss @@ -0,0 +1,175 @@ +.page { + min-height: 100vh; + background: #F8F9FC; +} + +.wrap { + padding: 30rpx 32rpx; +} + +/* Guardian Card */ +.guardian-card { + background: linear-gradient(135deg, #CF91D3 0%, #B06AB3 100%); + border-radius: 48rpx; + padding: 40rpx; + color: #ffffff; + box-shadow: 0 20rpx 40rpx rgba(176, 106, 179, 0.25); + margin-bottom: 48rpx; + position: relative; + overflow: hidden; +} + +.guardian-card::after { + content: ""; + position: absolute; + top: -20rpx; + right: -20rpx; + width: 200rpx; + height: 200rpx; + background: rgba(255, 255, 255, 0.1); + border-radius: 50%; + filter: blur(40rpx); +} + +.guardian-header { + display: flex; + align-items: center; + gap: 20rpx; + margin-bottom: 50rpx; +} + +.icon-box { + width: 80rpx; + height: 80rpx; + background: rgba(255, 255, 255, 0.2); + border-radius: 24rpx; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(10rpx); +} + +.guardian-title { + font-size: 36rpx; + font-weight: 800; + letter-spacing: 2rpx; +} + +.stats-row { + display: flex; + justify-content: space-between; + padding: 0 20rpx; +} + +.stat-col { + display: flex; + flex-direction: column; +} + +.stat-label { + font-size: 26rpx; + color: rgba(255, 255, 255, 0.85); + margin-bottom: 16rpx; + font-weight: 500; +} + +.stat-num { + font-size: 64rpx; + font-weight: 900; + line-height: 1; +} + +/* Section Header */ +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 30rpx; + padding: 0 10rpx; +} + +.header-left { + display: flex; + align-items: center; + gap: 16rpx; +} + +.header-title { + font-size: 34rpx; + font-weight: 800; + color: #1F2937; +} + +/* Member List */ +.member-list { + display: flex; + flex-direction: column; + gap: 24rpx; +} + +.member-card { + background: #ffffff; + border-radius: 32rpx; + padding: 30rpx; + display: flex; + align-items: center; + gap: 24rpx; + box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.02); + border: 2rpx solid transparent; + transition: all 0.2s; +} + +.member-card:active { + transform: scale(0.98); +} + +.member-avatar { + width: 96rpx; + height: 96rpx; + border-radius: 50%; + background: #f3f4f6; + border: 4rpx solid #FDF4F9; +} + +.member-info { + flex: 1; + min-width: 0; +} + +.name-row { + display: flex; + align-items: center; + gap: 16rpx; + margin-bottom: 8rpx; +} + +.member-name { + font-size: 32rpx; + font-weight: 800; + color: #111827; +} + +.tag-badge { + background: #B06AB3; + color: #ffffff; + font-size: 20rpx; + font-weight: 700; + padding: 4rpx 12rpx; + border-radius: 999rpx; +} + +.member-meta { + font-size: 24rpx; + color: #6B7280; + font-weight: 500; +} + +.loading, +.empty { + text-align: center; + color: #9ca3af; + font-weight: 800; + padding: 100rpx 0; + font-size: 28rpx; +} + diff --git a/pages/theme-travel-apply/theme-travel-apply.js b/pages/theme-travel-apply/theme-travel-apply.js new file mode 100644 index 0000000..6a3c5b7 --- /dev/null +++ b/pages/theme-travel-apply/theme-travel-apply.js @@ -0,0 +1,162 @@ +// pages/theme-travel-apply/theme-travel-apply.js +// 定制主题申请页面 +const api = require('../../utils/api') + +Page({ + data: { + statusBarHeight: 44, + navBarHeight: 44, + totalNavHeight: 88, + showForm: true, + applyStatus: 'none', + statusTitle: '', + statusDesc: '', + isReapply: false, + agreed: false, + formData: { + realName: '', + city: '', + phone: '', + remarks: '' + }, + canSubmit: false + }, + + onLoad(options) { + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + const menuButton = wx.getMenuButtonBoundingClientRect() + const navBarHeight = menuButton.height + (menuButton.top - statusBarHeight) * 2 + const totalNavHeight = statusBarHeight + navBarHeight + + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight, + isReapply: options.isReapply === 'true' + }) + + this.checkApplyStatus() + }, + + goBack() { + wx.navigateBack() + }, + + async checkApplyStatus() { + const token = wx.getStorageSync('auth_token') + if (!token) { + this.setData({ applyStatus: 'none' }) + return + } + + try { + const res = await api.request('/theme-travel/apply') + if (res.success && res.data) { + const data = res.data + if (data.status === 'approved') { + this.setData({ + applyStatus: 'approved', + statusTitle: '申请已通过', + statusDesc: '恭喜您成为主题旅游服务师!' + }) + } else if (data.status === 'pending') { + this.setData({ + applyStatus: 'pending', + statusTitle: '审核中', + statusDesc: '您的申请正在审核中,请耐心等待' + }) + } else if (data.status === 'rejected') { + this.setData({ + applyStatus: 'rejected', + statusTitle: '申请未通过', + statusDesc: data.rejectReason || '很抱歉,您的申请未通过审核' + }) + } + } + } catch (err) { + console.log('获取申请状态失败:', err) + this.setData({ applyStatus: 'none' }) + } + }, + + reapply() { + this.setData({ isReapply: true, applyStatus: 'none' }) + }, + + onInputChange(e) { + const field = e.currentTarget.dataset.field + this.setData({ [`formData.${field}`]: e.detail.value }) + this.checkCanSubmit() + }, + + toggleAgreement() { + this.setData({ agreed: !this.data.agreed }) + this.checkCanSubmit() + }, + + viewAgreement() { + wx.navigateTo({ url: '/pages/agreement/agreement?code=cooperation_service' }) + }, + + checkCanSubmit() { + const { formData, agreed } = this.data + + const canSubmit = + formData.realName && + formData.phone && + formData.phone.length === 11 && + agreed + + this.setData({ canSubmit }) + }, + + async submitApply() { + if (!this.data.canSubmit) return + + const { formData } = this.data + + if (!/^1[3-9]\d{9}$/.test(formData.phone)) { + wx.showToast({ title: '请输入正确的手机号', icon: 'none' }) + return + } + + wx.showLoading({ title: '提交中...' }) + try { + const res = await api.request('/theme-travel/apply', { + method: 'POST', + data: { + realName: formData.realName, + city: formData.city, + phone: formData.phone, + remarks: formData.remarks + } + }) + + if (res.success || res.code === 0) { + wx.showToast({ title: '申请已提交', icon: 'success' }) + this.setData({ + applyStatus: 'pending', + statusTitle: '审核中', + statusDesc: '您的申请正在审核中,请耐心等待', + isReapply: false + }) + } else { + wx.showToast({ title: res.message || '提交失败', icon: 'none' }) + } + } catch (err) { + if (err.code === 404) { + wx.showModal({ + title: '提示', + content: '该服务申请即将开放,敬请期待!', + showCancel: false, + confirmColor: '#b06ab3' + }) + } else { + wx.showToast({ title: err.message || '提交失败', icon: 'none' }) + } + } finally { + wx.hideLoading() + } + } +}) diff --git a/pages/theme-travel-apply/theme-travel-apply.json b/pages/theme-travel-apply/theme-travel-apply.json new file mode 100644 index 0000000..266dac9 --- /dev/null +++ b/pages/theme-travel-apply/theme-travel-apply.json @@ -0,0 +1,4 @@ +{ + "navigationStyle": "custom", + "navigationBarTitleText": "主题旅游申请" +} diff --git a/pages/theme-travel-apply/theme-travel-apply.wxml b/pages/theme-travel-apply/theme-travel-apply.wxml new file mode 100644 index 0000000..56368ef --- /dev/null +++ b/pages/theme-travel-apply/theme-travel-apply.wxml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + 定制主题 + + + + + + + + + + + + + + + {{statusTitle}} + {{statusDesc}} + + + + + + + + + 基本信息 + + + + + 姓名 + * + + + + + + + + + 所在城市 + (选填) + + + + + + + + + 手机号 + * + + + + + + + + + + + 备注信息 + + + + + + {{formData.remarks.length || 0}}/500 + + + + + + + + + + 我已阅读并同意 + 《合作入驻服务协议》 + + + + + + + + + + + diff --git a/pages/theme-travel-apply/theme-travel-apply.wxss b/pages/theme-travel-apply/theme-travel-apply.wxss new file mode 100644 index 0000000..9bba1df --- /dev/null +++ b/pages/theme-travel-apply/theme-travel-apply.wxss @@ -0,0 +1,201 @@ +/* 主题旅游申请页面样式 - 玫瑰紫版 v3.0 */ +.page-container { + min-height: 100vh; + background: linear-gradient(180deg, #E8C3D4 0%, #F5E6ED 100%); +} + +/* 固定导航栏容器 */ +.nav-container { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 999; + background: rgba(248, 249, 252, 0.75); + backdrop-filter: blur(20rpx) saturate(180%); +} + +/* 状态栏 */ +.status-bar { + background: transparent; +} + +/* 导航栏 */ +.nav-bar { + display: flex; + align-items: center; + justify-content: center; + padding: 0 32rpx; + background: transparent; + position: relative; +} + +.nav-back { + position: absolute; + left: 32rpx; + top: 50%; + transform: translateY(-50%); + width: 80rpx; + height: 80rpx; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +.back-icon { + width: 48rpx; + height: 48rpx; +} + +.nav-title { + font-size: 36rpx; + font-weight: 700; + color: #1F2937; + line-height: 1; +} + +/* 内容滚动区域 */ +.content-scroll { + height: 100vh; + box-sizing: border-box; +} + +.intro-section { padding: 16px; } + +.banner-card { + height: 160px; + border-radius: 16px; + overflow: hidden; + margin-bottom: 16px; + background: linear-gradient(135deg, #914584 0%, #7A3A6F 100%); + box-shadow: 0 4rpx 16rpx rgba(145, 69, 132, 0.25); +} + +.banner-gradient { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.banner-title { font-size: 24px; font-weight: 600; color: #fff; margin-bottom: 8px; } +.banner-subtitle { font-size: 14px; color: rgba(255,255,255,0.9); } + +.info-card { + background: #fff; + border-radius: 16px; + padding: 20px; + margin-bottom: 16px; + box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06), + 0 2rpx 8rpx rgba(0, 0, 0, 0.04); + transition: transform 0.25s ease, box-shadow 0.25s ease; +} + +.card-header { display: flex; align-items: center; margin-bottom: 16px; } + +.card-icon { + width: 32px; + height: 32px; + background: linear-gradient(135deg, #7C3AED 0%, #A78BFA 100%); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + margin-right: 12px; + box-shadow: 0 2rpx 8rpx rgba(124, 58, 237, 0.2); +} + +.card-icon image { width: 20px; height: 20px; } +.card-title { font-size: 18px; font-weight: 600; color: #111827; } +.intro-text { font-size: 14px; color: #6B7280; line-height: 1.8; } + +.service-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; } +.service-item { background: #f8f8f8; border-radius: 8px; padding: 12px 8px; text-align: center; transition: transform 0.2s ease; } +.service-item:active { transform: scale(0.95); } +.service-name { font-size: 13px; color: #111827; } + +.advantage-list { padding: 0; } +.advantage-item { display: flex; align-items: center; padding: 8px 0; } +.advantage-dot { width: 6px; height: 6px; background: linear-gradient(135deg, #7C3AED 0%, #A78BFA 100%); border-radius: 50%; margin-right: 12px; } +.advantage-text { font-size: 14px; color: #6B7280; } + +.apply-btn-area { padding: 24px 0; text-align: center; } +.apply-btn { width: 100%; height: 48px; background: linear-gradient(135deg, #7C3AED 0%, #A78BFA 100%); border-radius: 24px; color: #fff; font-size: 16px; font-weight: 500; border: none; margin-bottom: 12px; box-shadow: 0 4rpx 16rpx rgba(124, 58, 237, 0.35); transition: all 0.25s ease; } +.apply-btn:active { transform: scale(0.98); box-shadow: 0 2rpx 8rpx rgba(124, 58, 237, 0.4); } +.apply-tip { font-size: 13px; color: #9CA3AF; } + +.apply-form { padding: 16px; } +.status-card { background: #fff; border-radius: 16px; padding: 40px 24px; text-align: center; margin-bottom: 16px; } +.status-icon { width: 80px; height: 80px; margin: 0 auto 16px; } +.status-icon image { width: 100%; height: 100%; } +.status-title { display: block; font-size: 18px; font-weight: 600; color: #111827; margin-bottom: 8px; } +.status-desc { font-size: 14px; color: #6B7280; line-height: 1.6; } +.btn-secondary { margin-top: 24px; width: 160px; height: 44px; background: #fff; border: 1px solid #7C3AED; border-radius: 22px; color: #7C3AED; font-size: 15px; transition: all 0.2s ease; } +.btn-secondary:active { transform: scale(0.95); background: #FAF5FF; } + +.form-content { background: #fff; border-radius: 16px; padding: 24px 20px; box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06), + 0 2rpx 8rpx rgba(0, 0, 0, 0.04); } +.form-header { text-align: center; margin-bottom: 24px; } +.form-title { display: block; font-size: 20px; font-weight: 600; color: #111827; margin-bottom: 8px; } +.form-subtitle { font-size: 14px; color: #9CA3AF; } + +.form-section { margin-bottom: 24px; } +.section-header { display: flex; align-items: center; margin-bottom: 16px; } +.section-title { font-size: 16px; font-weight: 600; color: #111827; } +.required { color: #ff4d4f; margin-left: 4px; } + +.avatar-upload-area { display: flex; justify-content: center; margin-bottom: 8px; } +.avatar-circle { width: 100px; height: 100px; border-radius: 50%; background: linear-gradient(135deg, #E5E7EB 0%, #F3F4F6 100%); overflow: hidden; display: flex; align-items: center; justify-content: center; border: 2px solid #F1F5F9; } +.avatar-image { width: 100%; height: 100%; } +.upload-placeholder { text-align: center; } +.camera-icon { width: 32px; height: 32px; margin-bottom: 4px; } +.upload-text { font-size: 12px; color: #9CA3AF; } +.form-tip { font-size: 12px; color: #9CA3AF; margin-top: 8px; } + +.form-item { margin-bottom: 16px; } +.item-label-row { display: flex; align-items: center; margin-bottom: 8px; } +.item-label { font-size: 14px; color: #111827; } +.input-wrapper { background: #f8f8f8; border-radius: 8px; padding: 0 12px; transition: background 0.2s ease; } +.input-wrapper:focus-within { background: #F3F4F6; } +.item-input { width: 100%; height: 44px; font-size: 14px; color: #111827; } + +.gender-options { display: flex; gap: 12px; } +.gender-btn { flex: 1; height: 44px; background: #f8f8f8; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 14px; color: #6B7280; transition: all 0.2s ease; } +.gender-btn:active { transform: scale(0.95); } +.gender-btn.active { background: linear-gradient(135deg, #7C3AED 0%, #A78BFA 100%); color: #fff; font-weight: 500; box-shadow: 0 2rpx 8rpx rgba(124, 58, 237, 0.3); } + +.service-types { display: flex; flex-wrap: wrap; gap: 10px; } +.service-btn { padding: 8px 16px; background: #f8f8f8; border-radius: 20px; font-size: 13px; color: #6B7280; transition: all 0.2s ease; } +.service-btn:active { transform: scale(0.95); } +.service-btn.active { background: linear-gradient(135deg, #7C3AED 0%, #A78BFA 100%); color: #fff; box-shadow: 0 2rpx 8rpx rgba(124, 58, 237, 0.3); } + +.level-options { display: flex; gap: 10px; } +.level-btn { flex: 1; padding: 10px 8px; background: #f8f8f8; border-radius: 8px; font-size: 13px; color: #6B7280; text-align: center; transition: all 0.2s ease; } +.level-btn:active { transform: scale(0.95); } +.level-btn.active { background: linear-gradient(135deg, #7C3AED 0%, #A78BFA 100%); color: #fff; font-weight: 500; box-shadow: 0 2rpx 8rpx rgba(124, 58, 237, 0.3); } + +.cert-upload { width: 100%; height: 120px; background: linear-gradient(135deg, #E5E7EB 0%, #F3F4F6 100%); border-radius: 8px; border: 1px dashed #D1D5DB; display: flex; align-items: center; justify-content: center; overflow: hidden; transition: border-color 0.2s ease; } +.cert-upload:active { border-color: #7C3AED; } +.cert-image { width: 100%; height: 100%; } +.cert-placeholder { text-align: center; } +.upload-icon { width: 40px; height: 40px; margin-bottom: 8px; } + +.textarea-wrapper { background: #f8f8f8; border-radius: 8px; padding: 12px; } +.intro-textarea { width: 100%; height: 120px; font-size: 14px; color: #111827; line-height: 1.6; } +.textarea-footer { display: flex; justify-content: flex-end; margin-top: 8px; } +.char-count { font-size: 12px; color: #9CA3AF; } + +.agreement-row { display: flex; align-items: center; margin: 24px 0; } +.checkbox { width: 20px; height: 20px; border: 1px solid #D1D5DB; border-radius: 4px; margin-right: 8px; display: flex; align-items: center; justify-content: center; transition: all 0.2s ease; } +.checkbox.checked { background: linear-gradient(135deg, #7C3AED 0%, #A78BFA 100%); border-color: transparent; box-shadow: 0 2rpx 8rpx rgba(124, 58, 237, 0.3); } +.check-icon { width: 14px; height: 14px; } +.normal-text { font-size: 13px; color: #6B7280; } +.link-text { font-size: 13px; color: #7C3AED; } + +.submit-btn { width: 100%; height: 48px; background: linear-gradient(135deg, #7C3AED 0%, #A78BFA 100%); border-radius: 24px; color: #fff; font-size: 16px; font-weight: 500; border: none; box-shadow: 0 4rpx 16rpx rgba(124, 58, 237, 0.35); transition: all 0.25s ease; } +.submit-btn:active { transform: scale(0.98); box-shadow: 0 2rpx 8rpx rgba(124, 58, 237, 0.4); } +.submit-btn.disabled { opacity: 0.5; transform: none; } +.bottom-placeholder { height: 40px; } diff --git a/pages/theme-travel/theme-travel.js b/pages/theme-travel/theme-travel.js new file mode 100644 index 0000000..4fa628f --- /dev/null +++ b/pages/theme-travel/theme-travel.js @@ -0,0 +1,455 @@ +// pages/theme-travel/theme-travel.js - 高端定制页面 +const api = require('../../utils/api') +const app = getApp() + +Page({ + data: { + statusBarHeight: 44, + navBarHeight: 44, + totalNavHeight: 88, + loading: false, + loadingMore: false, + activeTab: 'featured', + + // 活动列表 + activityList: [], + + // 分页相关 + page: 1, + limit: 20, + hasMore: true, + total: 0, + + // 二维码引导弹窗 + showQrcodeModal: false, + qrcodeImageUrl: 'https://ai-c.maimanji.com/api/common/qrcode?type=theme-travel' + }, + + onLoad(options) { + // 计算导航栏高度 + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + const menuButton = wx.getMenuButtonBoundingClientRect() + const navBarHeight = menuButton.height + (menuButton.top - statusBarHeight) * 2 + const totalNavHeight = statusBarHeight + navBarHeight + + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight + }) + + this.loadActivityList() + }, + + /** + * 返回上一页 + */ + onBack() { + wx.navigateBack() + }, + + /** + * 加载活动列表(支持分页) + */ + async loadActivityList(isLoadMore = false) { + if (isLoadMore) { + this.setData({ loadingMore: true }) + } else { + this.setData({ loading: true, page: 1, hasMore: true, activityList: [] }) + } + + try { + const { activeTab, page, limit } = this.data + const params = { + category: 'travel', + limit: limit, + page: page + } + + if (activeTab === 'featured') { + params.tab = 'featured' + } else if (activeTab === 'free') { + params.priceType = 'free' + } else if (activeTab === 'vip') { + params.is_vip = true + } else if (activeTab === 'svip') { + params.is_svip = true + } + + const res = await api.activity.getList(params) + + if (res.success && res.data && res.data.list) { + const total = res.data.total || 0 + const allActivities = res.data.list + const travelActivities = allActivities.filter(item => item.categoryName === '高端定制') + + let clubQrcode = '' + const firstWithQrcode = travelActivities.find(item => item.activityGuideQrcode || item.activity_guide_qrcode) + if (firstWithQrcode && !isLoadMore) { + clubQrcode = firstWithQrcode.activityGuideQrcode || firstWithQrcode.activity_guide_qrcode + } + + const newActivityList = travelActivities.map(item => { + const heat = item.heat || (item.likes * 2 + (item.views || 0) + (item.current_participants || 0) * 3) + + return { + id: item.id, + title: item.title, + date: this.formatDate(item.start_date || item.activityDate, item.end_date || item.endDate), + location: item.location || '', + venue: item.venue || '', + image: item.coverImage || item.cover_image || '', + heat: Math.floor(heat), + price: item.price_text || item.priceText || '免费', + priceType: item.is_free || item.priceType === 'free' ? 'free' : 'paid', + likes: item.likes || item.likesCount || 0, + participants: item.current_participants || item.currentParticipants || 0, + isLiked: item.is_liked || item.isLiked || false, + isSignedUp: item.is_registered || item.isSignedUp || false, + status: item.status || (item.currentParticipants >= item.maxParticipants && item.maxParticipants > 0 ? 'full' : 'upcoming'), + activityGuideQrcode: item.activityGuideQrcode || item.activity_guide_qrcode || '' + } + }) + + const hasMore = newActivityList.length >= limit && (this.data.activityList.length + newActivityList.length) < total + + if (isLoadMore) { + this.setData({ + activityList: [...this.data.activityList, ...newActivityList], + loadingMore: false, + hasMore, + page: this.data.page + 1, + total + }) + } else { + this.setData({ + activityList: newActivityList, + hasMore, + total, + qrcodeImageUrl: clubQrcode || this.data.qrcodeImageUrl + }) + } + + console.log('[theme-travel] 加载成功,总数:', total, '当前:', this.data.activityList.length, 'hasMore:', hasMore) + } else { + if (isLoadMore) { + this.setData({ loadingMore: false, hasMore: false }) + } else { + this.setData({ activityList: [], hasMore: false }) + } + } + } catch (err) { + console.error('加载活动列表失败', err) + if (isLoadMore) { + this.setData({ loadingMore: false }) + } else { + this.setData({ activityList: [], loading: false }) + } + } finally { + if (!isLoadMore) { + this.setData({ loading: false }) + } + } + }, + + /** + * 标签切换 + */ + onTabChange(e) { + const tab = e.currentTarget.dataset.tab + if (tab === this.data.activeTab) return + + this.setData({ + activeTab: tab, + activityList: [], + page: 1, + hasMore: true + }) + this.loadActivityList() + }, + + /** + * 下拉刷新 + */ + onPullDownRefresh() { + this.loadActivityList(false).finally(() => { + wx.stopPullDownRefresh() + }) + }, + + /** + * 上拉加载更多 + */ + onReachBottom() { + if (this.data.hasMore && !this.data.loadingMore && !this.data.loading) { + this.loadActivityList(true) + } + }, + + /** + * 加载模拟数据(降级方案) + */ + loadMockActivities() { + // 使用空数据,等待后端API返回真实数据 + const mockActivities = [] + + this.setData({ activityList: mockActivities }) + }, + + /** + * 格式化日期 + */ + formatDate(startDate, endDate) { + if (!startDate) return '' + + const formatSingle = (dateStr) => { + const date = new Date(dateStr) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}年${month}月${day}日` + } + + // 如果有结束日期且不同于开始日期,显示日期范围 + if (endDate && endDate !== startDate) { + const startFormatted = formatSingle(startDate) + const endFormatted = formatSingle(endDate) + // 如果是同一年,省略结束日期的年份 + if (startFormatted.split('年')[0] === endFormatted.split('年')[0]) { + return `${startFormatted}-${endFormatted.split('年')[1]}` + } + return `${startFormatted}-${endFormatted}` + } + + return formatSingle(startDate) + }, + + /** + * 立即报名 + */ + async onSignUp(e) { + const id = e.currentTarget.dataset.id + const index = e.currentTarget.dataset.index + + if (!app.globalData.isLoggedIn) { + wx.navigateTo({ url: '/pages/login/login' }) + return + } + + const activity = this.data.activityList[index] + + // 检查活动状态 + if (activity.status === 'full' || activity.status === 'ended') { + const qrCode = activity.activityGuideQrcode || activity.activity_guide_qrcode || this.data.qrcodeImageUrl + this.setData({ + qrcodeImageUrl: qrCode, + showQrcodeModal: true + }) + return + } + + try { + if (activity.isSignedUp) { + // 取消报名 + const res = await api.activity.cancelSignup(id) + if (res.success) { + wx.showToast({ title: '已取消报名', icon: 'success' }) + this.loadActivityList() + } + } else { + // 报名 + const res = await api.activity.signup(id) + if (res.success) { + wx.showToast({ title: '报名成功', icon: 'success' }) + this.loadActivityList() + } else { + // 检查是否需要显示二维码(后端开关关闭或活动已结束) + if (res.code === 'QR_CODE_REQUIRED' || res.error === 'QR_CODE_REQUIRED' || res.code === 'ACTIVITY_ENDED' || res.error === '活动已结束') { + if (activity.activityGuideQrcode || activity.activity_guide_qrcode) { + this.setData({ qrcodeImageUrl: activity.activityGuideQrcode || activity.activity_guide_qrcode }) + } + this.setData({ showQrcodeModal: true }) + if (res.code === 'ACTIVITY_ENDED' || res.error === '活动已结束') { + wx.showToast({ title: '活动已结束,进群查看更多', icon: 'none' }) + } + } else { + wx.showToast({ + title: res.error || '报名失败', + icon: 'none' + }) + } + } + } + } catch (err) { + console.error('报名操作失败', err) + // 捕获特定错误码以显示二维码 + const isQrRequired = err && (err.code === 'QR_CODE_REQUIRED' || (err.data && err.data.code === 'QR_CODE_REQUIRED')) + const isActivityEnded = err && (err.code === 'ACTIVITY_ENDED' || (err.data && err.data.code === 'ACTIVITY_ENDED') || err.error === '活动已结束') + + if (isQrRequired || isActivityEnded) { + if (activity.activityGuideQrcode || activity.activity_guide_qrcode) { + this.setData({ qrcodeImageUrl: activity.activityGuideQrcode || activity.activity_guide_qrcode }) + } + this.setData({ showQrcodeModal: true }) + if (isActivityEnded) { + wx.showToast({ title: '活动已结束,进群查看更多', icon: 'none' }) + } + } else { + wx.showToast({ + title: err.error || err.message || '操作失败', + icon: 'none' + }) + } + } + }, + + /** + * 点赞/取消点赞 + */ + async onLike(e) { + const id = e.currentTarget.dataset.id + const index = e.currentTarget.dataset.index + + if (!app.globalData.isLoggedIn) { + wx.navigateTo({ url: '/pages/login/login' }) + return + } + + try { + const res = await api.activity.toggleLike(id) + if (res.success) { + this.setData({ + [`activityList[${index}].isLiked`]: res.data.isLiked, + [`activityList[${index}].likes`]: res.data.likesCount + }) + } + } catch (err) { + console.error('点赞失败', err) + wx.showToast({ title: '操作失败', icon: 'none' }) + } + }, + + /** + * 点击活动卡片 + */ + onActivityTap(e) { + const id = e.currentTarget.dataset.id + wx.navigateTo({ + url: `/pages/activity-detail/activity-detail?id=${id}` + }) + }, + + /** + * 加入高端定制群 + */ + onJoinGroup() { + let qrcodeUrl = this.data.qrcodeImageUrl + + if (!qrcodeUrl && this.data.activityList && this.data.activityList.length > 0) { + const firstWithQrcode = this.data.activityList.find(item => item.activityGuideQrcode || item.activity_guide_qrcode) + if (firstWithQrcode) { + qrcodeUrl = firstWithQrcode.activityGuideQrcode || firstWithQrcode.activity_guide_qrcode + } + } + + if (!qrcodeUrl) { + wx.showToast({ title: '暂无二维码', icon: 'none' }) + return + } + + this.setData({ + showQrcodeModal: true, + qrcodeImageUrl: qrcodeUrl + }) + }, + + /** + * 关闭二维码弹窗 + */ + onCloseQrcodeModal() { + this.setData({ + showQrcodeModal: false + }) + }, + + /** + * 保存二维码 + */ + async onSaveQrcode() { + try { + const { qrcodeImageUrl } = this.data + if (!qrcodeImageUrl) { + wx.showToast({ title: '二维码链接不存在', icon: 'none' }) + return + } + + wx.showLoading({ title: '保存中...' }) + + let filePath = '' + + // 判断是否是 Base64 格式 + if (qrcodeImageUrl.startsWith('data:image')) { + const fs = wx.getFileSystemManager() + const [, format, bodyData] = /data:image\/(\w+);base64,(.*)/.exec(qrcodeImageUrl) || [] + if (!format || !bodyData) { + throw new Error('Base64 格式错误') + } + filePath = `${wx.env.USER_DATA_PATH}/qrcode_${Date.now()}.${format}` + fs.writeFileSync(filePath, bodyData, 'base64') + } else { + // 远程 URL 格式 + const downloadRes = await new Promise((resolve, reject) => { + wx.downloadFile({ + url: qrcodeImageUrl, + success: resolve, + fail: reject + }) + }) + + if (downloadRes.statusCode !== 200) { + throw new Error('下载图片失败') + } + filePath = downloadRes.tempFilePath + } + + // 保存到相册 + await new Promise((resolve, reject) => { + wx.saveImageToPhotosAlbum({ + filePath: filePath, + success: resolve, + fail: reject + }) + }) + + wx.hideLoading() + wx.showToast({ title: '保存成功', icon: 'success' }) + this.onCloseQrcodeModal() + } catch (err) { + wx.hideLoading() + console.error('保存二维码失败', err) + + if (err.errMsg && (err.errMsg.includes('auth deny') || err.errMsg.includes('auth denied'))) { + wx.showModal({ + title: '需要授权', + content: '请允许访问相册以保存二维码', + confirmText: '去设置', + success: (res) => { + if (res.confirm) { + wx.openSetting() + } + } + }) + } else { + wx.showToast({ title: err.message || '保存失败', icon: 'none' }) + } + } + }, + + /** + * 阻止冒泡 + */ + preventBubble() { + return + } +}) diff --git a/pages/theme-travel/theme-travel.json b/pages/theme-travel/theme-travel.json new file mode 100644 index 0000000..a3b4779 --- /dev/null +++ b/pages/theme-travel/theme-travel.json @@ -0,0 +1,9 @@ +{ + "navigationStyle": "custom", + "navigationBarTextStyle": "black", + "usingComponents": { + "app-icon": "../../components/icon/icon" + }, + "enablePullDownRefresh": true, + "backgroundTextStyle": "dark" +} diff --git a/pages/theme-travel/theme-travel.wxml b/pages/theme-travel/theme-travel.wxml new file mode 100644 index 0000000..1c9295c --- /dev/null +++ b/pages/theme-travel/theme-travel.wxml @@ -0,0 +1,134 @@ + + + + + + + + + + + + + 高端定制 + + + + + + + + + 高端定制俱乐部 + + 结伴同行 + 精彩旅程 + + + + 点击立即加入 + + + + + + 热门线路 + 共 {{activityList.length}} 条线路 + + + + + + + + + + + + + + {{item.price}} + + + + + {{item.title}} + + + + {{item.date}} + + + + + {{item.location}} · {{item.venue}} + + + + + {{item.heat}} + + + + + + + + {{item.participants}}人已报名 + + + + + + + + + 暂无线路 + + + + 没有更多线路了 ~ + + + + + + + + + + + + + + + 加入高端定制群 + 进群获取更多定制资讯,开启您的精彩旅程 + + + + 长按二维码识别或保存 + + 保存二维码 + + + + diff --git a/pages/theme-travel/theme-travel.wxss b/pages/theme-travel/theme-travel.wxss new file mode 100644 index 0000000..fe5c385 --- /dev/null +++ b/pages/theme-travel/theme-travel.wxss @@ -0,0 +1,607 @@ +/* 主题旅行页面样式 - 优雅蓝紫主题 */ +page { + background: linear-gradient(180deg, #D1C4E9 0%, #E8EAF6 100%); +} + +.page-container { + min-height: 100vh; + background: linear-gradient(180deg, #D1C4E9 0%, #E8EAF6 100%); +} + +/* 固定导航栏容器 */ +.nav-container { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 999; + background: rgba(248, 249, 252, 0.75); + backdrop-filter: blur(20rpx) saturate(180%); + -webkit-backdrop-filter: blur(20rpx) saturate(180%); +} + +/* 状态栏 */ +.status-bar { + background: transparent; +} + +/* 导航栏 */ +.nav-bar { + display: flex; + align-items: center; + justify-content: center; + padding: 0 32rpx; + background: transparent; + position: relative; +} + +.nav-back { + position: absolute; + left: 32rpx; + top: 50%; + transform: translateY(-50%); + width: 80rpx; + height: 80rpx; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +.back-icon { + width: 48rpx; + height: 48rpx; +} + +.nav-title { + font-size: 36rpx; + font-weight: 700; + color: #1A1A1A; + line-height: 1; +} + +/* 内容滚动区域 */ +.content-scroll { + height: 100vh; + box-sizing: border-box; +} + +/* 推广卡片 - 优雅蓝紫渐变 */ +.city-group-card { + margin: 32rpx; + padding: 32rpx 40rpx; + min-height: 128rpx; + background: linear-gradient(135deg, + rgba(209, 196, 233, 0.6) 0%, + rgba(232, 234, 246, 0.6) 100%); + backdrop-filter: blur(16rpx) saturate(150%); + border: 2rpx solid rgba(103, 58, 183, 0.3); + border-radius: 48rpx; + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: 0 4rpx 20rpx rgba(103, 58, 183, 0.12); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.city-group-card:active { + transform: scale(0.98); + box-shadow: 0 2rpx 12rpx rgba(103, 58, 183, 0.18); +} + +.group-info { + flex: 1; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8rpx; + padding-right: 24rpx; +} + +.group-title { + font-size: 40rpx; + font-weight: 700; + color: #1A1A1A; + line-height: 1.4; + white-space: nowrap; +} + +.group-tags { + display: flex; + flex-direction: column; + gap: 4rpx; +} + +.tag-item { + font-size: 28rpx; + font-weight: 500; + color: #4A5565; + line-height: 1.4; + white-space: nowrap; +} + +.join-btn { + padding: 0 40rpx; + height: 88rpx; + background: linear-gradient(135deg, #7E57C2 0%, #5E35B1 100%); + border-radius: 100rpx; + display: flex; + align-items: center; + justify-content: center; + font-size: 32rpx; + font-weight: 700; + color: #fff; + white-space: nowrap; + flex-shrink: 0; + box-shadow: 0 6rpx 24rpx rgba(103, 58, 183, 0.4), + 0 3rpx 12rpx rgba(103, 58, 183, 0.3); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; +} + +.join-btn:active { + transform: scale(0.96); + box-shadow: 0 4rpx 16rpx rgba(103, 58, 183, 0.45); +} + +/* 活动标签切换 - 横向滚动 */ +.tab-section { + padding: 32rpx 0; + background: transparent; + margin: 0 32rpx 32rpx; + position: relative; + z-index: 1; +} + +.tab-scroll { + width: 100%; + white-space: nowrap; +} + +.tab-scroll::-webkit-scrollbar { + display: none; +} + +.tab-list { + display: inline-flex; + gap: 20rpx; + padding: 0 4rpx; +} + +.tab-item { + padding: 20rpx 48rpx; + border-radius: 100rpx; + font-size: 32rpx; + font-weight: 700; + color: #6A7282; + background: rgba(255, 255, 255, 0.6); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; + flex-shrink: 0; + white-space: nowrap; +} + +.tab-item:active { + transform: scale(0.96); +} + +.tab-item.active { + color: #fff; + background: linear-gradient(135deg, #7E57C2 0%, #5E35B1 100%); + box-shadow: 0 12rpx 24rpx rgba(103, 58, 183, 0.3); + transform: scale(1.02); +} + +/* 活动列表标题 */ +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 32rpx; + margin-bottom: 32rpx; +} + +.section-title { + font-size: 44rpx; + font-weight: 700; + color: #1A1A1A; +} + +.activity-count { + font-size: 28rpx; + color: #5E35B1; + font-weight: 500; +} + +/* 活动列表 - 毛玻璃卡片 */ +.activity-list { + padding: 0 32rpx; +} + +.activity-card { + margin-bottom: 32rpx; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(16rpx); + border-radius: 32rpx; + overflow: hidden; + box-shadow: 0 8rpx 32rpx rgba(103, 58, 183, 0.12), + 0 4rpx 16rpx rgba(103, 58, 183, 0.08); + border: 1rpx solid rgba(103, 58, 183, 0.15); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.activity-card:active { + transform: scale(0.98); + box-shadow: 0 4rpx 16rpx rgba(103, 58, 183, 0.15); +} + +/* 活动图片容器 */ +.activity-image-wrap { + position: relative; + width: 100%; + height: 440rpx; + overflow: hidden; + background: linear-gradient(135deg, #E8EAF6 0%, #F3E5F5 100%); +} + +.activity-image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.activity-image-gradient { + width: 100%; + height: 100%; + background: linear-gradient(135deg, #E8EAF6 0%, #F3E5F5 100%); +} + +/* 点赞徽章 */ +.like-badge { + position: absolute; + top: 24rpx; + right: 24rpx; + display: flex; + align-items: center; + gap: 8rpx; + padding: 10rpx 20rpx; + background: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(10rpx); + border-radius: 100rpx; + z-index: 10; + box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1); +} + +.like-icon { + width: 32rpx; + height: 32rpx; +} + +.like-count { + font-size: 24rpx; + color: #4A5565; + font-weight: 600; +} + +.like-badge.liked .like-count { + color: #FF5252; +} + +/* 价格标签 */ +.price-tag { + position: absolute; + bottom: 24rpx; + left: 24rpx; + padding: 10rpx 24rpx; + border-radius: 12rpx; + font-size: 24rpx; + font-weight: 700; + color: #FFFFFF; + z-index: 10; + box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.15); +} + +.price-tag.free { + background: #4CAF50; +} + +.price-tag.paid { + background: #7E57C2; +} + +.location-badge { + position: absolute; + top: 24rpx; + left: 24rpx; + padding: 12rpx 24rpx; + background: rgba(49, 27, 146, 0.85); + backdrop-filter: blur(12rpx); + border-radius: 100rpx; + display: flex; + align-items: center; + gap: 8rpx; + font-size: 24rpx; + color: #FFFFFF; + font-weight: 500; + box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.15); +} + +.location-icon { + width: 24rpx; + height: 24rpx; +} + +/* 活动信息 */ +.activity-info { + padding: 40rpx; +} + +.activity-title { + font-size: 36rpx; + font-weight: 700; + color: #4527A0; + margin-bottom: 20rpx; + line-height: 1.4; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; +} + +.activity-meta { + display: flex; + align-items: center; + gap: 32rpx; + margin-bottom: 24rpx; +} + +.meta-item { + display: flex; + align-items: center; + gap: 8rpx; + font-size: 26rpx; + color: #5E35B1; +} + +.meta-icon { + width: 28rpx; + height: 28rpx; +} + +.meta-text { + font-size: 26rpx; + color: #4A5565; +} + +.meta-row { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + margin-top: 8rpx; +} + +/* 活动底部 */ +.activity-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 24rpx; + border-top: 1rpx solid rgba(103, 58, 183, 0.1); +} + +.participants { + display: flex; + align-items: center; + gap: 12rpx; +} + +.avatar-stack { + display: flex; + align-items: center; +} + +.mini-avatar { + width: 48rpx; + height: 48rpx; + border-radius: 50%; + background: #E8EAF6; + border: 2rpx solid #fff; + margin-left: -12rpx; +} + +.mini-avatar:first-child { + margin-left: 0; +} + +.participant-text { + font-size: 24rpx; + color: #62748E; +} + +.heat-item { + margin-left: auto; +} + +.heat-text { + color: #FF9800; + font-weight: 600; +} + +/* 立即报名按钮 */ +.signup-btn { + width: 220rpx; + height: 72rpx; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #7E57C2 0%, #5E35B1 100%); + border-radius: 100rpx; + font-size: 28rpx; + font-weight: 700; + color: #FFFFFF; + box-shadow: 0 6rpx 20rpx rgba(103, 58, 183, 0.3); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + white-space: nowrap; + flex-shrink: 0; +} + +.signup-btn:active { + transform: scale(0.95); + box-shadow: 0 4rpx 12rpx rgba(103, 58, 183, 0.35); +} + +/* 空状态 */ +.empty-state { + padding: 120rpx 32rpx; + text-align: center; +} + +.empty-icon { + width: 200rpx; + height: 200rpx; + margin: 0 auto 32rpx; + opacity: 0.5; +} + +.empty-text { + font-size: 28rpx; + color: #9575CD; +} + +/* 二维码弹窗 */ +.qrcode-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + visibility: hidden; + opacity: 0; + transition: all 0.3s ease; +} + +.qrcode-modal.show { + visibility: visible; + opacity: 1; +} + +/* 遮罩层 */ +.modal-mask { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4rpx); +} + +/* 弹窗内容 */ +.modal-content { + position: relative; + width: 680rpx; + background: #FFFFFF; + border-radius: 64rpx; + padding: 64rpx; + box-shadow: 0 50rpx 100rpx -24rpx rgba(0, 0, 0, 0.25); + display: flex; + flex-direction: column; + align-items: center; + z-index: 1; +} + +/* 关闭按钮 */ +.close-btn { + position: absolute; + top: 32rpx; + right: 32rpx; + width: 72rpx; + height: 72rpx; + background: #F1F5F9; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s ease; +} + +.close-btn:active { + transform: scale(0.9); + background: #E2E8F0; +} + +.close-icon { + width: 40rpx; + height: 40rpx; +} + +/* 标题 */ +.modal-title { + font-size: 48rpx; + font-weight: 700; + color: #1D293D; + text-align: center; + margin-bottom: 16rpx; + line-height: 1.5; +} + +/* 副标题 */ +.modal-subtitle { + font-size: 32rpx; + color: #62748E; + text-align: center; + margin-bottom: 48rpx; + line-height: 1.5; +} + +/* 二维码容器 */ +.qrcode-container { + width: 440rpx; + height: 440rpx; + background: #F8FAFC; + border: 2rpx solid #F1F5F9; + border-radius: 40rpx; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 48rpx; + overflow: hidden; +} + +.qrcode-image { + width: 404rpx; + height: 404rpx; + border-radius: 24rpx; +} + +.modal-tips { + font-size: 24rpx; + color: #999; + margin-bottom: 24rpx; +} + +.save-btn { + width: 552rpx; + height: 116rpx; + background: #07C160; + border-radius: 100rpx; + display: flex; + align-items: center; + justify-content: center; + font-size: 40rpx; + font-weight: 700; + color: #FFFFFF; + box-shadow: 0 20rpx 30rpx -6rpx rgba(220, 252, 231, 1), + 0 8rpx 12rpx -8rpx rgba(220, 252, 231, 1); + transition: all 0.3s ease; +} + +.save-btn:active { + transform: scale(0.96); + box-shadow: 0 10rpx 20rpx -6rpx rgba(220, 252, 231, 1); +} diff --git a/pages/webview/webview.js b/pages/webview/webview.js new file mode 100644 index 0000000..88cbe46 --- /dev/null +++ b/pages/webview/webview.js @@ -0,0 +1,34 @@ +// Webview 页面 +Page({ + data: { + webUrl: '', + title: '' + }, + + onLoad(options) { + if (options.url) { + this.setData({ + webUrl: decodeURIComponent(options.url) + }) + } + if (options.title) { + wx.setNavigationBarTitle({ + title: decodeURIComponent(options.title) + }) + this.setData({ title: decodeURIComponent(options.title) }) + } + }, + + goBack() { + wx.navigateBack() + }, + + onShareAppMessage() { + const referralCode = wx.getStorageSync('referralCode') || '' + const referralCodeParam = referralCode ? `&referralCode=${referralCode}` : '' + return { + title: this.data.title || '关于品牌', + path: `/pages/webview/webview?url=${encodeURIComponent(this.data.webUrl)}&title=${encodeURIComponent(this.data.title || '')}${referralCodeParam}` + } + } +}) diff --git a/pages/webview/webview.json b/pages/webview/webview.json new file mode 100644 index 0000000..afde5da --- /dev/null +++ b/pages/webview/webview.json @@ -0,0 +1,4 @@ +{ + "usingComponents": {}, + "navigationBarTitleText": "关于品牌" +} diff --git a/pages/webview/webview.wxml b/pages/webview/webview.wxml new file mode 100644 index 0000000..f57149a --- /dev/null +++ b/pages/webview/webview.wxml @@ -0,0 +1,15 @@ + + + + + + + 返回 + + {{title || '加载中...'}} + + + + + + diff --git a/pages/webview/webview.wxss b/pages/webview/webview.wxss new file mode 100644 index 0000000..1756979 --- /dev/null +++ b/pages/webview/webview.wxss @@ -0,0 +1,58 @@ +.page-container { + min-height: 100vh; + background: #f5f5f5; +} + +.unified-header { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 88rpx; + padding-top: var(--status-bar-height, 44rpx); + background: linear-gradient(135deg, #B06AB3 0%, #9B59B6 100%); + display: flex; + align-items: center; + justify-content: space-between; + padding-left: 30rpx; + padding-right: 30rpx; + z-index: 1000; +} + +.unified-header-left { + display: flex; + align-items: center; + min-width: 120rpx; +} + +.unified-back-icon { + width: 48rpx; + height: 48rpx; +} + +.unified-back-text { + color: #fff; + font-size: 28rpx; + margin-left: 8rpx; +} + +.unified-header-title { + color: #fff; + font-size: 34rpx; + font-weight: 500; + flex: 1; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.unified-header-right { + min-width: 120rpx; +} + +web-view { + width: 100%; + height: calc(100vh - 88rpx); + margin-top: calc(88rpx + var(--status-bar-height, 44rpx)); +} diff --git a/pages/withdraw-records/withdraw-records.js b/pages/withdraw-records/withdraw-records.js new file mode 100644 index 0000000..ebf9a92 --- /dev/null +++ b/pages/withdraw-records/withdraw-records.js @@ -0,0 +1,186 @@ +// pages/withdraw-records/withdraw-records.js +const api = require('../../utils/api') +const util = require('../../utils/util') + +Page({ + data: { + // 导航栏高度 + statusBarHeight: 44, + navBarHeight: 44, + totalNavHeight: 88, + + // 提现记录列表 + list: [], + + // 分页 + page: 1, + pageSize: 20, + total: 0, + hasMore: true, + + // 筛选状态 + currentStatus: 'all', // all, pending, approved, rejected + statusList: [ + { value: 'all', label: '全部' }, + { value: 'pending', label: '待审核' }, + { value: 'approved', label: '已通过' }, + { value: 'rejected', label: '已拒绝' } + ], + + // 状态 + loading: false, + isEmpty: false + }, + + onLoad(options) { + // 计算导航栏高度 + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 44 + const menuButton = wx.getMenuButtonBoundingClientRect() + const navBarHeight = menuButton.height + (menuButton.top - statusBarHeight) * 2 + const totalNavHeight = statusBarHeight + navBarHeight + + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight + }) + + // 加载数据 + this.loadWithdrawRecords() + }, + + /** + * 加载提现记录列表 + */ + async loadWithdrawRecords(page = 1) { + if (this.data.loading) return + + this.setData({ loading: true }) + + try { + const params = { + page, + pageSize: this.data.pageSize + } + + // 添加状态筛选 + if (this.data.currentStatus !== 'all') { + params.status = this.data.currentStatus + } + + const res = await api.commission.getWithdrawals(params) + + if (res.success && res.data) { + const dataList = res.data.list || [] + const list = page === 1 ? dataList : [...this.data.list, ...dataList] + const hasMore = dataList.length === this.data.pageSize + const isEmpty = list.length === 0 + + this.setData({ + list, + page, + total: res.data.total || 0, + hasMore, + isEmpty, + loading: false + }) + } else { + throw new Error(res.message || '加载失败') + } + } catch (error) { + console.error('加载提现记录失败:', error) + this.setData({ loading: false }) + wx.showToast({ + title: error.message || '加载失败', + icon: 'none' + }) + } + }, + + /** + * 切换状态筛选 + */ + onStatusChange(e) { + const status = e.currentTarget.dataset.status + if (status === this.data.currentStatus) return + + this.setData({ + currentStatus: status, + page: 1, + list: [], + hasMore: true + }) + + this.loadWithdrawRecords(1) + }, + + /** + * 获取状态文本 + */ + getStatusText(status) { + const statusMap = { + 'pending': '待审核', + 'approved': '已通过', + 'rejected': '已拒绝' + } + return statusMap[status] || status + }, + + /** + * 获取状态样式类 + */ + getStatusClass(status) { + const classMap = { + 'pending': 'status-pending', + 'approved': 'status-approved', + 'rejected': 'status-rejected' + } + return classMap[status] || '' + }, + + /** + * 格式化金额 + */ + formatMoney(amount) { + return util.formatMoney(amount) + }, + + /** + * 格式化时间 + */ + formatTime(timestamp) { + return util.formatDate(timestamp) + }, + + /** + * 下拉刷新 + */ + onPullDownRefresh() { + this.loadWithdrawRecords(1).then(() => { + wx.stopPullDownRefresh() + }) + }, + + /** + * 上拉加载更多 + */ + onReachBottom() { + if (!this.data.hasMore || this.data.loading) return + this.loadWithdrawRecords(this.data.page + 1) + }, + + /** + * 返回上一页 + */ + onBack() { + wx.navigateBack() + }, + + /** + * 重新加载 + */ + onRetry() { + this.loadWithdrawRecords(1) + } +}) diff --git a/pages/withdraw-records/withdraw-records.json b/pages/withdraw-records/withdraw-records.json new file mode 100644 index 0000000..1c9dd73 --- /dev/null +++ b/pages/withdraw-records/withdraw-records.json @@ -0,0 +1,6 @@ +{ + "navigationStyle": "custom", + "enablePullDownRefresh": true, + "backgroundTextStyle": "dark", + "backgroundColor": "#F8F5FF" +} diff --git a/pages/withdraw-records/withdraw-records.wxml b/pages/withdraw-records/withdraw-records.wxml new file mode 100644 index 0000000..47e9b7d --- /dev/null +++ b/pages/withdraw-records/withdraw-records.wxml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + 提现记录 + + + + + + + + + {{item.label}} + + + + + + + + + 单号: {{item.orderNo}} + + {{getStatusText(item.status)}} + + + + + + + 提现金额 + ¥{{formatMoney(item.amount)}} + + + 手续费 + ¥{{formatMoney(item.fee)}} + + + 实际到账 + ¥{{formatMoney(item.actualAmount)}} + + + + + + + 申请时间 + {{formatTime(item.createdAt)}} + + + 处理时间 + {{formatTime(item.processedAt)}} + + + + + + 拒绝原因 + {{item.rejectReason}} + + + + + 💡 + 预计1-3个工作日到账 + + + + + + + + 暂无提现记录 + 完成推广任务后即可申请提现 + + + + + 加载中... + + + + + 没有更多了 + + + + + + diff --git a/pages/withdraw-records/withdraw-records.wxss b/pages/withdraw-records/withdraw-records.wxss new file mode 100644 index 0000000..9d868e8 --- /dev/null +++ b/pages/withdraw-records/withdraw-records.wxss @@ -0,0 +1,303 @@ +/* pages/withdraw-records/withdraw-records.wxss */ + +/* 页面容器 */ +.page { + min-height: 100vh; + background: linear-gradient(180deg, #F8F5FF 0%, #FFFFFF 100%); +} + +/* 固定导航栏容器 */ +.nav-container { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 999; + background: rgba(242, 237, 255, 0.6); + backdrop-filter: blur(10px); +} + +/* 状态栏 */ +.status-bar { + background: transparent; +} + +/* 导航栏 */ +.nav-bar { + display: flex; + align-items: center; + justify-content: center; + padding: 0 32rpx; + background: transparent; + position: relative; +} + +.nav-back { + position: absolute; + left: 32rpx; + top: 50%; + transform: translateY(-50%); + width: 80rpx; + height: 80rpx; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +.back-icon { + width: 48rpx; + height: 48rpx; +} + +.nav-title { + font-size: 36rpx; + font-weight: 700; + color: #1F2937; + line-height: 1; +} + +/* 内容滚动区域 */ +.content-scroll { + height: 100vh; + box-sizing: border-box; +} + +/* 状态筛选栏 */ +.filter-bar { + display: flex; + align-items: center; + padding: 24rpx 32rpx; + background: #FFFFFF; + margin: 24rpx 32rpx; + border-radius: 16rpx; + box-shadow: 0 4rpx 12rpx rgba(176, 106, 179, 0.08); +} + +.filter-item { + flex: 1; + text-align: center; + padding: 16rpx 0; + font-size: 28rpx; + color: #6B7280; + border-radius: 12rpx; + transition: all 0.3s; +} + +.filter-item.active { + background: linear-gradient(135deg, #B06AB3 0%, #9B4D9E 100%); + color: #FFFFFF; + font-weight: 600; +} + +/* 提现记录列表 */ +.records-list { + padding: 0 32rpx; +} + +.record-card { + background: #FFFFFF; + border-radius: 20rpx; + padding: 32rpx; + margin-bottom: 24rpx; + box-shadow: 0 4rpx 16rpx rgba(176, 106, 179, 0.1); +} + +/* 记录头部 */ +.record-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24rpx; + padding-bottom: 24rpx; + border-bottom: 1rpx solid #F3F4F6; +} + +.order-no { + font-size: 26rpx; + color: #6B7280; +} + +.status { + padding: 8rpx 20rpx; + border-radius: 20rpx; + font-size: 24rpx; + font-weight: 600; +} + +.status-pending { + background: #FEF3C7; + color: #D97706; +} + +.status-approved { + background: #D1FAE5; + color: #059669; +} + +.status-rejected { + background: #FEE2E2; + color: #DC2626; +} + +/* 金额信息 */ +.amount-section { + margin-bottom: 24rpx; +} + +.amount-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16rpx; +} + +.amount-row:last-child { + margin-bottom: 0; +} + +.amount-row.highlight { + padding-top: 16rpx; + border-top: 1rpx dashed #E5E7EB; +} + +.amount-row .label { + font-size: 28rpx; + color: #6B7280; +} + +.amount-row .value { + font-size: 28rpx; + color: #1F2937; + font-weight: 600; +} + +.amount-row .value.primary { + font-size: 36rpx; + color: #B06AB3; + font-weight: 700; +} + +/* 时间信息 */ +.time-section { + padding-top: 24rpx; + border-top: 1rpx solid #F3F4F6; +} + +.time-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12rpx; +} + +.time-row:last-child { + margin-bottom: 0; +} + +.time-row .label { + font-size: 26rpx; + color: #9CA3AF; +} + +.time-row .value { + font-size: 26rpx; + color: #6B7280; +} + +/* 拒绝原因 */ +.reject-reason { + margin-top: 24rpx; + padding: 20rpx; + background: #FEF2F2; + border-radius: 12rpx; + border-left: 4rpx solid #DC2626; +} + +.reason-label { + font-size: 26rpx; + color: #DC2626; + font-weight: 600; + margin-bottom: 8rpx; +} + +.reason-text { + font-size: 26rpx; + color: #991B1B; + line-height: 1.6; +} + +/* 到账说明 */ +.arrival-tip { + margin-top: 24rpx; + padding: 20rpx; + background: #F0FDF4; + border-radius: 12rpx; + display: flex; + align-items: center; +} + +.tip-icon { + font-size: 32rpx; + margin-right: 12rpx; +} + +.tip-text { + font-size: 26rpx; + color: #059669; +} + +/* 空状态 */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 120rpx 32rpx; +} + +.empty-icon { + width: 240rpx; + height: 240rpx; + margin-bottom: 32rpx; + opacity: 0.6; +} + +.empty-text { + font-size: 32rpx; + color: #6B7280; + font-weight: 600; + margin-bottom: 16rpx; +} + +.empty-tip { + font-size: 26rpx; + color: #9CA3AF; +} + +/* 加载状态 */ +.loading-more { + text-align: center; + padding: 40rpx 0; +} + +.loading-text { + font-size: 26rpx; + color: #9CA3AF; +} + +/* 没有更多 */ +.no-more { + text-align: center; + padding: 40rpx 0; +} + +.no-more-text { + font-size: 26rpx; + color: #D1D5DB; +} + +/* 底部占位 */ +.bottom-placeholder { + height: 40rpx; +} diff --git a/pages/withdraw/withdraw.js b/pages/withdraw/withdraw.js new file mode 100644 index 0000000..bea067b --- /dev/null +++ b/pages/withdraw/withdraw.js @@ -0,0 +1,132 @@ +const { request } = require('../../utils_new/request'); + +Page({ + data: { + statusBarHeight: 20, + navBarHeight: 44, + totalNavHeight: 64, + balance: '0.00', + amount: '', + withdrawType: 'wechat', + withdrawTypeText: '微信', + submitting: false, + records: [], + withdrawConfig: { + minWithdrawAmount: 1 + } + }, + onLoad() { + const sys = wx.getSystemInfoSync(); + const menu = wx.getMenuButtonBoundingClientRect(); + const statusBarHeight = sys.statusBarHeight || 20; + const navBarHeight = menu.height + (menu.top - statusBarHeight) * 2; + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight: statusBarHeight + navBarHeight + }); + this.load(); + this.fetchConfig(); + this.fetchRecords(); + }, + onBack() { + wx.navigateBack({ delta: 1 }); + }, + async fetchConfig() { + try { + const res = await request({ url: '/api/withdraw/config', method: 'GET' }); + if (res.data && res.data.code === 0 && res.data.data) { + this.setData({ + withdrawConfig: res.data.data + }); + } + } catch (err) { + console.error('Fetch withdraw config failed', err); + } + }, + async fetchRecords() { + try { + const res = await request({ url: '/api/commission?action=withdrawals', method: 'GET' }); + const body = res.data || {}; + if (body.success && body.data) { + const records = body.data.list.map(item => { + // 格式化时间 + const date = new Date(item.createdAt); + item.timeStr = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`; + + // 状态映射 + const statusMap = { + 'pending': '待审核', + 'processing': '打款中', + 'completed': '已通过', + 'rejected': '已拒绝' + }; + item.statusText = statusMap[item.status] || '未知'; + return item; + }); + this.setData({ records }); + } + } catch (err) { + console.error('Fetch records failed', err); + } + }, + async load() { + try { + try { + const res = await request({ url: '/api/commission?action=stats', method: 'GET' }); + const body = res.data || {}; + if (!body.success) throw new Error(); + const balance = Number(body.data?.commissionBalance || 0).toFixed(2); + this.setData({ balance }); + } catch (err) { + this.setData({ balance: '888.00' }); // Mock data + } + } catch (e) {} + }, + onAmount(e) { + this.setData({ amount: e.detail.value }); + }, + fillAll() { + this.setData({ amount: this.data.balance }); + }, + async submit() { + if (this.data.submitting) return; + const amountNum = Number(this.data.amount || 0); + const minAmount = this.data.withdrawConfig.minWithdrawAmount || 0; + + if (!amountNum || amountNum <= 0) { + wx.showToast({ title: '请输入正确金额', icon: 'none' }); + return; + } + + if (amountNum < minAmount) { + wx.showToast({ title: `最低提现金额为 ¥${minAmount.toFixed(2)}`, icon: 'none' }); + return; + } + + this.setData({ submitting: true }); + try { + const res = await request({ + url: '/api/commission', + method: 'POST', + data: { + action: 'withdraw', + amount: amountNum, + withdrawType: this.data.withdrawType, + accountInfo: {} + } + }); + const body = res.data || {}; + if (!body.success) throw new Error(body.error || '提交失败'); + wx.showToast({ title: '提交申请成功', icon: 'success' }); + this.setData({ amount: '' }); + this.load(); + this.fetchRecords(); + } catch (e) { + wx.showToast({ title: e.message || '提交失败', icon: 'none' }); + } finally { + this.setData({ submitting: false }); + } + } +}); + diff --git a/pages/withdraw/withdraw.json b/pages/withdraw/withdraw.json new file mode 100644 index 0000000..3153ca5 --- /dev/null +++ b/pages/withdraw/withdraw.json @@ -0,0 +1,5 @@ +{ + "usingComponents": { + "app-icon": "/components/icon/icon" + } +} diff --git a/pages/withdraw/withdraw.wxml b/pages/withdraw/withdraw.wxml new file mode 100644 index 0000000..d450c5e --- /dev/null +++ b/pages/withdraw/withdraw.wxml @@ -0,0 +1,83 @@ + + + + + + 返回 + + 余额提现 + + + + + + + + 可提现佣金 + + + + ¥ + {{balance}} + + + + 最低提现金额 ¥{{withdrawConfig.minWithdrawAmount || '1.00'}} + + + + + + + 提现金额 + + ¥ + + 全部 + + + + + 提现方式 + + + + + + 微信零钱 + + + + + + + + + + + + 提现记录 + + + + + 微信提现 + {{item.timeStr}} + + + -¥{{item.amount}} + {{item.statusText}} + + + + 拒绝原因: + {{item.rejectReason}} + + + + + + + diff --git a/pages/withdraw/withdraw.wxss b/pages/withdraw/withdraw.wxss new file mode 100644 index 0000000..ffd984a --- /dev/null +++ b/pages/withdraw/withdraw.wxss @@ -0,0 +1,302 @@ +.page { + min-height: 100vh; + background: linear-gradient(180deg, #F8F5FF 0%, #FFFFFF 100%); + padding-bottom: env(safe-area-inset-bottom); +} + +/* 顶部导航栏已移除,改用全局 unified-header */ + +.wrap { + padding: 0 32rpx; +} + +.card { + background: #ffffff; + border-radius: 48rpx; + padding: 40rpx; + box-shadow: 0 4rpx 16rpx rgba(176, 106, 179, 0.08); + margin-bottom: 32rpx; + border: 2rpx solid #FDF4F9; +} + +/* Balance Card */ +.balance-card { + background: linear-gradient(135deg, #FFFFFF 0%, #FDF4F9 100%); +} + +.balance-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24rpx; +} + +.label { + font-size: 28rpx; + font-weight: 700; + color: #6B7280; +} + +.balance-row { + display: flex; + align-items: baseline; + gap: 8rpx; + margin-bottom: 32rpx; +} + +.currency { + font-size: 40rpx; + font-weight: 900; + color: #111827; +} + +.value { + font-size: 80rpx; + font-weight: 900; + color: #111827; + line-height: 1; + letter-spacing: -2rpx; +} + +.withdraw-tip { + display: inline-flex; + align-items: center; + gap: 8rpx; + padding: 12rpx 24rpx; + background: rgba(176, 106, 179, 0.1); + border-radius: 999rpx; +} + +.withdraw-tip text { + font-size: 24rpx; + color: #B06AB3; + font-weight: 600; +} + +/* Form Card */ +.field-group { + margin-bottom: 48rpx; +} + +.f-label { + display: block; + font-size: 28rpx; + font-weight: 800; + color: #111827; + margin-bottom: 20rpx; +} + +.input-wrapper { + background: #F9FAFB; + border-radius: 32rpx; + padding: 8rpx 32rpx; + display: flex; + align-items: center; + height: 112rpx; + border: 2rpx solid transparent; + transition: all 0.3s; +} + +.input-wrapper:focus-within { + background: #FFFFFF; + border-color: #B06AB3; + box-shadow: 0 0 0 4rpx rgba(176, 106, 179, 0.1); +} + +.input-prefix { + font-size: 40rpx; + font-weight: 900; + color: #111827; + margin-right: 16rpx; +} + +.input { + flex: 1; + height: 100%; + font-size: 40rpx; + font-weight: 900; + color: #111827; +} + +.placeholder { + color: #D1D5DB; + font-weight: 600; +} + +.all-btn { + font-size: 28rpx; + font-weight: 700; + color: #B06AB3; + padding: 12rpx 24rpx; +} + +.select-wrapper { + background: #F9FAFB; + border-radius: 32rpx; + padding: 24rpx 32rpx; + display: flex; + align-items: center; + justify-content: space-between; + height: 112rpx; +} + +.select-left { + display: flex; + align-items: center; + gap: 16rpx; +} + +.wechat-icon-box { + width: 64rpx; + height: 64rpx; + background: #FFFFFF; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.select-text { + font-size: 30rpx; + font-weight: 700; + color: #111827; +} + +.submit-btn { + width: 100%; + height: 104rpx; + border-radius: 52rpx; + background: linear-gradient(135deg, #B06AB3 0%, #9B4D9E 100%); + color: #ffffff; + font-size: 36rpx; + font-weight: 900; + box-shadow: 0 20rpx 40rpx rgba(176, 106, 179, 0.3); + display: flex; + align-items: center; + justify-content: center; + margin-top: 64rpx; + letter-spacing: 2rpx; + transition: opacity 0.3s; +} + +.submit-btn[disabled] { + opacity: 0.6; + box-shadow: none; +} + +/* Records Section */ +.records-section { + margin-top: 48rpx; + padding-bottom: 64rpx; +} + +.section-header { + margin-bottom: 24rpx; + padding: 0 8rpx; +} + +.section-title { + font-size: 32rpx; + font-weight: 800; + color: #111827; +} + +.record-list { + display: flex; + flex-direction: column; + gap: 24rpx; +} + +.record-item { + background: #ffffff; + border-radius: 32rpx; + padding: 32rpx; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.02); + border: 2rpx solid #F9FAFB; +} + +.record-info { + display: flex; + flex-direction: column; + gap: 8rpx; + flex: 1; +} + +.record-type { + font-size: 28rpx; + font-weight: 700; + color: #111827; +} + +.record-time { + font-size: 24rpx; + color: #9CA3AF; +} + +.record-amount { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8rpx; +} + +.amount-value { + font-size: 32rpx; + font-weight: 800; + color: #111827; +} + +.record-status { + font-size: 22rpx; + font-weight: 700; + padding: 4rpx 16rpx; + border-radius: 999rpx; +} + +/* Rejection Reason Style */ +.reject-reason { + width: 100%; + margin-top: 20rpx; + padding-top: 20rpx; + border-top: 2rpx dashed #F3F4F6; + display: flex; + flex-direction: row; + align-items: flex-start; +} + +.reason-label { + font-size: 24rpx; + font-weight: 700; + color: #DC2626; + white-space: nowrap; +} + +.reason-content { + font-size: 24rpx; + color: #4B5563; + line-height: 1.4; +} + +.status-pending { + background: #FEF3C7; + color: #D97706; +} + +.status-processing { + background: #DBEAFE; + color: #2563EB; +} + +.status-completed { + background: #D1FAE5; + color: #059669; +} + +.status-rejected { + background: #FEE2E2; + color: #DC2626; +} diff --git a/pages/workbench/workbench.js b/pages/workbench/workbench.js new file mode 100644 index 0000000..65b4877 --- /dev/null +++ b/pages/workbench/workbench.js @@ -0,0 +1,320 @@ +// pages/workbench/workbench.js +const api = require('../../utils/api') +const app = getApp() + +Page({ + data: { + userInfo: {}, + currentStatus: 'offline', + statusText: '离线', + showStatusModal: false, + // 等级信息 + levelCode: 'junior', + levelName: '初级', + textPrice: 0.5, + voicePrice: 1, + stats: { + todayOrders: 0, + todayIncome: '0.00', + totalOrders: 0, + totalIncome: '0.00' + }, + hallOrders: [], + activeOrders: [], + loading: false + }, + + onLoad() { + this.setData({ + userInfo: app.globalData.userInfo || {} + }) + }, + + onShow() { + this.loadCompanionStatus() + this.loadWorkbenchData() + this.loadHallOrders() + this.loadActiveOrders() + }, + + // 加载陪聊师状态(包含等级信息) + async loadCompanionStatus() { + try { + const res = await api.companion.getStatus() + if (res.success && res.data) { + const data = res.data + this.setData({ + currentStatus: data.onlineStatus || 'offline', + statusText: this.getStatusText(data.onlineStatus || 'offline'), + // 等级信息 + levelCode: data.levelCode || 'junior', + levelName: data.levelName || '初级', + textPrice: data.textPrice || 0.5, + voicePrice: data.voicePrice || 1 + }) + } + } catch (err) { + console.error('加载陪聊师状态失败:', err) + } + }, + + // 加载工作台数据 + async loadWorkbenchData() { + try { + const res = await api.companion.getWorkbench() + if (res.success) { + const data = res.data || {} + this.setData({ + stats: { + todayOrders: data.todayOrders || data.todayStats?.orderCount || 0, + todayIncome: (data.todayIncome || data.todayStats?.totalAmount || 0).toFixed(2), + totalOrders: data.totalOrders || data.monthStats?.orderCount || 0, + totalIncome: (data.totalIncome || data.monthStats?.totalAmount || 0).toFixed(2) + } + }) + } + } catch (err) { + console.error('加载工作台数据失败:', err) + } + }, + + // 加载接单大厅订单 + async loadHallOrders() { + try { + const res = await api.order.getHall() + if (res.success) { + const orders = (res.data || []).map(order => ({ + ...order, + createTimeText: this.formatTime(order.created_at), + serviceTypeText: this.getServiceTypeText(order.service_type) + })) + this.setData({ hallOrders: orders }) + } + } catch (err) { + console.error('加载接单大厅失败:', err) + } + }, + + // 加载进行中的订单 + async loadActiveOrders() { + try { + const res = await api.companion.getOrders({ status: 'in_progress' }) + if (res.success) { + const orders = (res.data?.list || []).map(order => ({ + ...order, + remainingTime: this.calculateRemainingTime(order) + })) + this.setData({ activeOrders: orders }) + } + } catch (err) { + console.error('加载进行中订单失败:', err) + } + }, + + // 获取状态文本 + getStatusText(status) { + const statusMap = { + 'online': '在线接单', + 'busy': '忙碌中', + 'offline': '离线' + } + return statusMap[status] || '离线' + }, + + // 获取服务类型文本 + getServiceTypeText(type) { + const typeMap = { + 'chat': '文字聊天', + 'voice': '语音聊天', + 'video': '视频聊天' + } + return typeMap[type] || '聊天服务' + }, + + // 格式化时间 + formatTime(timeStr) { + if (!timeStr) return '' + const date = new Date(timeStr) + const now = new Date() + const diff = now - date + + if (diff < 60000) return '刚刚' + if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前' + if (diff < 86400000) return Math.floor(diff / 3600000) + '小时前' + return `${date.getMonth() + 1}/${date.getDate()}` + }, + + // 计算剩余时间 + calculateRemainingTime(order) { + if (!order.start_time || !order.duration) return '未知' + const startTime = new Date(order.start_time).getTime() + const endTime = startTime + order.duration * 60 * 1000 + const remaining = endTime - Date.now() + + if (remaining <= 0) return '已超时' + const minutes = Math.floor(remaining / 60000) + return `${minutes}分钟` + }, + + // 显示状态选择器 + showStatusPicker() { + this.setData({ showStatusModal: true }) + }, + + // 隐藏状态选择器 + hideStatusPicker() { + this.setData({ showStatusModal: false }) + }, + + // 切换状态 + async changeStatus(e) { + const status = e.currentTarget.dataset.status + if (status === this.data.currentStatus) { + this.hideStatusPicker() + return + } + + wx.showLoading({ title: '切换中...' }) + try { + const res = await api.companion.updateStatus(status) + if (res.success) { + this.setData({ + currentStatus: status, + statusText: this.getStatusText(status) + }) + wx.showToast({ title: '状态已更新', icon: 'success' }) + } else { + wx.showToast({ title: res.message || '切换失败', icon: 'none' }) + } + } catch (err) { + wx.showToast({ title: '切换失败', icon: 'none' }) + } finally { + wx.hideLoading() + this.hideStatusPicker() + } + }, + + // 刷新接单大厅 + refreshHall() { + wx.showLoading({ title: '刷新中...' }) + this.loadHallOrders().finally(() => { + wx.hideLoading() + wx.showToast({ title: '已刷新', icon: 'success' }) + }) + }, + + // 接受订单 + async acceptOrder(e) { + const orderId = e.currentTarget.dataset.id + + wx.showModal({ + title: '确认接单', + content: '确定要接受这个订单吗?', + success: async (res) => { + if (res.confirm) { + wx.showLoading({ title: '处理中...' }) + try { + const result = await api.order.accept(orderId) + if (result.success) { + wx.showToast({ title: '接单成功', icon: 'success' }) + this.loadHallOrders() + this.loadActiveOrders() + this.loadWorkbenchData() + } else { + wx.showToast({ title: result.message || '接单失败', icon: 'none' }) + } + } catch (err) { + wx.showToast({ title: '接单失败', icon: 'none' }) + } finally { + wx.hideLoading() + } + } + } + }) + }, + + // 拒绝订单 + async rejectOrder(e) { + const orderId = e.currentTarget.dataset.id + + wx.showModal({ + title: '拒绝订单', + content: '确定要拒绝这个订单吗?', + success: async (res) => { + if (res.confirm) { + wx.showLoading({ title: '处理中...' }) + try { + const result = await api.order.reject(orderId, '陪聊师暂时无法接单') + if (result.success) { + wx.showToast({ title: '已拒绝', icon: 'success' }) + this.loadHallOrders() + } else { + wx.showToast({ title: result.message || '操作失败', icon: 'none' }) + } + } catch (err) { + wx.showToast({ title: '操作失败', icon: 'none' }) + } finally { + wx.hideLoading() + } + } + } + }) + }, + + // 结束服务 + async endService(e) { + const orderId = e.currentTarget.dataset.id + + wx.showModal({ + title: '结束服务', + content: '确定要结束这个服务吗?', + success: async (res) => { + if (res.confirm) { + wx.showLoading({ title: '处理中...' }) + try { + const result = await api.order.endService(orderId) + if (result.success) { + wx.showToast({ title: '服务已结束', icon: 'success' }) + this.loadActiveOrders() + this.loadWorkbenchData() + } else { + wx.showToast({ title: result.message || '操作失败', icon: 'none' }) + } + } catch (err) { + wx.showToast({ title: '操作失败', icon: 'none' }) + } finally { + wx.hideLoading() + } + } + } + }) + }, + + // 跳转到聊天 + goToChat(e) { + const order = e.currentTarget.dataset.order + wx.navigateTo({ + url: `/pages/companion-chat/companion-chat?orderId=${order.id}&userId=${order.user_id}` + }) + }, + + // 跳转到订单列表 + goToOrders() { + wx.navigateTo({ url: '/pages/companion-orders/companion-orders' }) + }, + + // 跳转到客户管理 + goToCustomers() { + wx.navigateTo({ url: '/pages/customer-management/customer-management' }) + }, + + // 跳转到提现 + goToWithdraw() { + wx.navigateTo({ url: '/pages/withdraw/withdraw' }) + }, + + // 跳转到佣金明细 + goToCommission() { + wx.navigateTo({ url: '/pages/commission/commission' }) + } +}) diff --git a/pages/workbench/workbench.json b/pages/workbench/workbench.json new file mode 100644 index 0000000..9740c19 --- /dev/null +++ b/pages/workbench/workbench.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "工作台", + "navigationBarBackgroundColor": "#E8C3D4", + "usingComponents": {} +} diff --git a/pages/workbench/workbench.wxml b/pages/workbench/workbench.wxml new file mode 100644 index 0000000..b5d9ad3 --- /dev/null +++ b/pages/workbench/workbench.wxml @@ -0,0 +1,175 @@ + + + + var DEFAULT_AVATAR = 'https://ai-c.maimanji.com/images/default-avatar.png'; + module.exports = { + DEFAULT_AVATAR: DEFAULT_AVATAR, + getAvatar: function(avatar) { + return avatar || DEFAULT_AVATAR; + } + }; + + + + + + + + {{userInfo.nickname || '陪聊师'}} + + {{statusText}} + + + + + 切换状态 + + + + + + + + + {{levelName}} + + 当前等级 + + + + 文字服务 + ¥{{textPrice}}/分钟 + + + + 语音服务 + ¥{{voicePrice}}/分钟 + + + + + + + + + {{stats.todayOrders || 0}} + 今日订单 + + + ¥{{stats.todayIncome || '0.00'}} + 今日收入 + + + + + {{stats.totalOrders || 0}} + 总订单数 + + + ¥{{stats.totalIncome || '0.00'}} + 总收入 + + + + + + + + + 我的订单 + + + + 客户管理 + + + + 提现 + + + + 佣金明细 + + + + + + + 接单大厅 + 刷新 + + + + + + + + + + {{item.serviceTypeText}} + {{item.duration}}分钟 + ¥{{item.amount}} + + + {{item.message}} + + + + + + + + + + + 暂无待接订单 + + + + + + + 进行中的订单 + + + + + + + + + + + + + + + + + + + + + 选择状态 + + + + + 在线接单 + + + + 忙碌中 + + + + 离线 + + + diff --git a/pages/workbench/workbench.wxss b/pages/workbench/workbench.wxss new file mode 100644 index 0000000..9f8d632 --- /dev/null +++ b/pages/workbench/workbench.wxss @@ -0,0 +1,509 @@ +/* pages/workbench/workbench.wxss */ +.container { + min-height: 100vh; + background: linear-gradient(180deg, #E8C3D4 0%, #F5E6EC 100%); + padding: 20rpx; + padding-bottom: 40rpx; +} + +/* 状态栏 */ +.status-bar { + display: flex; + justify-content: space-between; + align-items: center; + background: #fff; + border-radius: 20rpx; + padding: 30rpx; + margin-bottom: 20rpx; + box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05); +} + +.status-info { + display: flex; + align-items: center; +} + +.avatar { + width: 100rpx; + height: 100rpx; + border-radius: 50%; + margin-right: 20rpx; +} + +.status-text { + display: flex; + flex-direction: column; +} + +.name { + font-size: 32rpx; + font-weight: 600; + color: #333; + margin-bottom: 8rpx; +} + +.status-tag { + display: inline-flex; + align-items: center; + padding: 6rpx 16rpx; + border-radius: 20rpx; + font-size: 24rpx; +} + +.status-tag.online { + background: #e8f5e9; + color: #4caf50; +} + +.status-tag.busy { + background: #fff3e0; + color: #ff9800; +} + +.status-tag.offline { + background: #f5f5f5; + color: #9e9e9e; +} + +.status-switch { + display: flex; + align-items: center; + color: #b06ab3; + font-size: 28rpx; +} + +.status-switch .arrow { + width: 24rpx; + height: 24rpx; + margin-left: 8rpx; +} + +/* 数据统计卡片 */ +.stats-card { + background: linear-gradient(135deg, #b06ab3 0%, #d4a5d6 100%); + border-radius: 20rpx; + padding: 30rpx; + margin-bottom: 20rpx; + box-shadow: 0 4rpx 20rpx rgba(176, 106, 179, 0.3); +} + +.stats-row { + display: flex; + justify-content: space-around; + margin-bottom: 20rpx; +} + +.stats-row:last-child { + margin-bottom: 0; +} + +.stat-item { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; +} + +.stat-value { + font-size: 40rpx; + font-weight: 700; + color: #fff; + margin-bottom: 8rpx; +} + +.stat-label { + font-size: 24rpx; + color: rgba(255, 255, 255, 0.8); +} + +/* 快捷操作 */ +.quick-actions { + display: flex; + justify-content: space-around; + background: #fff; + border-radius: 20rpx; + padding: 30rpx 20rpx; + margin-bottom: 20rpx; + box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05); +} + +.action-item { + display: flex; + flex-direction: column; + align-items: center; +} + +.action-item image { + width: 60rpx; + height: 60rpx; + margin-bottom: 12rpx; +} + +.action-item text { + font-size: 24rpx; + color: #666; +} + +/* 接单大厅 */ +.order-hall { + background: #fff; + border-radius: 20rpx; + padding: 30rpx; + margin-bottom: 20rpx; + box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05); +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20rpx; +} + +.section-title { + font-size: 32rpx; + font-weight: 600; + color: #333; +} + +.section-more { + font-size: 26rpx; + color: #b06ab3; +} + +.hall-list { + display: flex; + flex-direction: column; + gap: 20rpx; +} + +.hall-item { + background: #fafafa; + border-radius: 16rpx; + padding: 24rpx; +} + +.hall-user { + display: flex; + align-items: center; + margin-bottom: 16rpx; +} + +.user-avatar { + width: 80rpx; + height: 80rpx; + border-radius: 50%; + margin-right: 16rpx; +} + +.user-info { + display: flex; + flex-direction: column; +} + +.user-name { + font-size: 28rpx; + font-weight: 500; + color: #333; +} + +.order-time { + font-size: 24rpx; + color: #999; + margin-top: 4rpx; +} + +.hall-content { + display: flex; + align-items: center; + gap: 16rpx; + margin-bottom: 12rpx; +} + +.service-type { + background: #e8c3d4; + color: #b06ab3; + padding: 6rpx 16rpx; + border-radius: 8rpx; + font-size: 24rpx; +} + +.service-duration { + font-size: 26rpx; + color: #666; +} + +.service-price { + font-size: 32rpx; + font-weight: 600; + color: #b06ab3; + margin-left: auto; +} + +.hall-message { + background: #fff; + padding: 16rpx; + border-radius: 8rpx; + margin-bottom: 16rpx; +} + +.hall-message text { + font-size: 26rpx; + color: #666; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.hall-actions { + display: flex; + justify-content: flex-end; + gap: 20rpx; +} + +.btn-reject { + background: #f5f5f5; + color: #666; + font-size: 26rpx; + padding: 12rpx 32rpx; + border-radius: 30rpx; + border: none; +} + +.btn-accept { + background: linear-gradient(135deg, #b06ab3 0%, #d4a5d6 100%); + color: #fff; + font-size: 26rpx; + padding: 12rpx 32rpx; + border-radius: 30rpx; + border: none; +} + +.empty-hall { + display: flex; + flex-direction: column; + align-items: center; + padding: 60rpx 0; +} + +.empty-hall image { + width: 200rpx; + height: 200rpx; + margin-bottom: 20rpx; + opacity: 0.5; +} + +.empty-hall text { + font-size: 28rpx; + color: #999; +} + +/* 进行中的订单 */ +.active-orders { + background: #fff; + border-radius: 20rpx; + padding: 30rpx; + box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05); +} + +.active-list { + display: flex; + flex-direction: column; + gap: 16rpx; +} + +.active-item { + display: flex; + justify-content: space-between; + align-items: center; + background: #fafafa; + border-radius: 16rpx; + padding: 20rpx; +} + +.active-user { + display: flex; + align-items: center; +} + +.remaining-time { + font-size: 24rpx; + color: #ff9800; + margin-top: 4rpx; +} + +.btn-end { + background: #ff5722; + color: #fff; + font-size: 24rpx; + padding: 10rpx 24rpx; + border-radius: 30rpx; + border: none; +} + +/* 状态选择弹窗 */ +.status-picker-mask { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 100; +} + +.status-picker { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: #fff; + border-radius: 30rpx 30rpx 0 0; + padding: 30rpx; + z-index: 101; + animation: slideUp 0.3s ease; +} + +@keyframes slideUp { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} + +.picker-header { + text-align: center; + padding-bottom: 30rpx; + border-bottom: 1rpx solid #eee; +} + +.picker-header text { + font-size: 32rpx; + font-weight: 600; + color: #333; +} + +.picker-options { + padding: 20rpx 0; +} + +.picker-option { + display: flex; + align-items: center; + padding: 30rpx 20rpx; + border-radius: 12rpx; +} + +.picker-option.active { + background: #f5e6ec; +} + +.status-dot { + width: 20rpx; + height: 20rpx; + border-radius: 50%; + margin-right: 20rpx; +} + +.status-dot.online { + background: #4caf50; +} + +.status-dot.busy { + background: #ff9800; +} + +.status-dot.offline { + background: #9e9e9e; +} + +.picker-option text { + font-size: 30rpx; + color: #333; +} + + +/* 等级信息卡片 */ +.level-card { + background: #fff; + border-radius: 20rpx; + padding: 30rpx; + margin-bottom: 20rpx; + box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05); +} + +.level-header { + display: flex; + align-items: center; + margin-bottom: 24rpx; +} + +.level-badge { + padding: 8rpx 24rpx; + border-radius: 20rpx; + margin-right: 16rpx; +} + +.level-badge.level-junior { + background: linear-gradient(135deg, #a8d8ea 0%, #6bb3d9 100%); +} + +.level-badge.level-intermediate { + background: linear-gradient(135deg, #b8e986 0%, #7bc96f 100%); +} + +.level-badge.level-senior { + background: linear-gradient(135deg, #ffd700 0%, #ffb347 100%); +} + +.level-badge.level-expert { + background: linear-gradient(135deg, #e8b4d8 0%, #c984cd 100%); +} + +.level-text { + font-size: 28rpx; + font-weight: 600; + color: #fff; +} + +.level-title { + font-size: 28rpx; + color: #666; +} + +.level-prices { + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #fce7f3 0%, #f3e8ff 100%); + border-radius: 16rpx; + padding: 24rpx; +} + +.level-prices .price-item { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; +} + +.level-prices .price-label { + font-size: 24rpx; + color: #6a7282; + margin-bottom: 8rpx; +} + +.level-prices .price-value { + font-size: 32rpx; + font-weight: 700; + color: #e91e63; +} + +.level-prices .price-divider { + width: 2rpx; + height: 60rpx; + background: rgba(0, 0, 0, 0.1); +} diff --git a/project.config.json b/project.config.json new file mode 100644 index 0000000..94c7716 --- /dev/null +++ b/project.config.json @@ -0,0 +1,58 @@ +{ + "description": "中文版手机交友App - 微信小程序", + "packOptions": { + "ignore": [], + "include": [] + }, + "setting": { + "bundle": false, + "userConfirmedBundleSwitch": false, + "urlCheck": true, + "scopeDataCheck": false, + "coverView": true, + "es6": true, + "postcss": true, + "compileHotReLoad": false, + "lazyloadPlaceholderEnable": false, + "preloadBackgroundData": false, + "minified": true, + "autoAudits": false, + "newFeature": false, + "uglifyFileName": false, + "uploadWithSourceMap": true, + "useIsolateContext": true, + "nodeModules": false, + "enhance": true, + "useMultiFrameRuntime": true, + "useApiHook": true, + "useApiHostProcess": true, + "showShadowRootInWxmlPanel": true, + "packNpmManually": false, + "packNpmRelationList": [], + "minifyWXSS": true, + "showES6CompileOption": false, + "minifyWXML": true, + "babelSetting": { + "ignore": [], + "disablePlugins": [], + "outputPath": "" + }, + "compileWorklet": false, + "localPlugins": false, + "disableUseStrict": false, + "useCompilerPlugins": false, + "condition": false, + "swc": false, + "disableSWC": true + }, + "compileType": "miniprogram", + "libVersion": "3.13.1", + "appid": "wx02babe43d1ef4434", + "projectname": "dating-miniprogram", + "condition": {}, + "editorSetting": { + "tabIndent": "insertSpaces", + "tabSize": 2 + }, + "simulatorPluginLibVersion": {} +} \ No newline at end of file diff --git a/project.private.config.json b/project.private.config.json new file mode 100644 index 0000000..264cc82 --- /dev/null +++ b/project.private.config.json @@ -0,0 +1,24 @@ +{ + "libVersion": "3.13.1", + "projectname": "%E5%BF%83%E4%BC%B4", + "condition": {}, + "setting": { + "urlCheck": false, + "coverView": true, + "lazyloadPlaceholderEnable": false, + "skylineRenderEnable": false, + "preloadBackgroundData": false, + "autoAudits": false, + "useApiHook": true, + "useApiHostProcess": true, + "showShadowRootInWxmlPanel": true, + "useStaticServer": false, + "useLanDebug": false, + "showES6CompileOption": false, + "compileHotReLoad": true, + "checkInvalidKey": true, + "ignoreDevUnusedFiles": true, + "bigPackageSizeSupport": false, + "useIsolateContext": true + } +} \ No newline at end of file diff --git a/sitemap.json b/sitemap.json new file mode 100644 index 0000000..55d1d29 --- /dev/null +++ b/sitemap.json @@ -0,0 +1,7 @@ +{ + "desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html", + "rules": [{ + "action": "allow", + "page": "*" + }] +} diff --git a/subpackages/cooperation/images/icon_back.png b/subpackages/cooperation/images/icon_back.png new file mode 100644 index 0000000..1cc49dd Binary files /dev/null and b/subpackages/cooperation/images/icon_back.png differ diff --git a/subpackages/cooperation/images/icon_chevron_gray.png b/subpackages/cooperation/images/icon_chevron_gray.png new file mode 100644 index 0000000..8f17970 Binary files /dev/null and b/subpackages/cooperation/images/icon_chevron_gray.png differ diff --git a/subpackages/cooperation/images/icon_chevron_white.png b/subpackages/cooperation/images/icon_chevron_white.png new file mode 100644 index 0000000..15a5278 Binary files /dev/null and b/subpackages/cooperation/images/icon_chevron_white.png differ diff --git a/subpackages/cooperation/images/icon_eldercare.png b/subpackages/cooperation/images/icon_eldercare.png new file mode 100644 index 0000000..c7de2ea Binary files /dev/null and b/subpackages/cooperation/images/icon_eldercare.png differ diff --git a/subpackages/cooperation/images/icon_entertainment.png b/subpackages/cooperation/images/icon_entertainment.png new file mode 100644 index 0000000..e24d352 Binary files /dev/null and b/subpackages/cooperation/images/icon_entertainment.png differ diff --git a/subpackages/cooperation/images/icon_housekeeping.png b/subpackages/cooperation/images/icon_housekeeping.png new file mode 100644 index 0000000..d20f938 Binary files /dev/null and b/subpackages/cooperation/images/icon_housekeeping.png differ diff --git a/subpackages/cooperation/images/icon_medical.png b/subpackages/cooperation/images/icon_medical.png new file mode 100644 index 0000000..e60dfcb Binary files /dev/null and b/subpackages/cooperation/images/icon_medical.png differ diff --git a/subpackages/cooperation/images/icon_orders.png b/subpackages/cooperation/images/icon_orders.png new file mode 100644 index 0000000..d40583b Binary files /dev/null and b/subpackages/cooperation/images/icon_orders.png differ diff --git a/subpackages/cooperation/pages/cooperation/cooperation.js b/subpackages/cooperation/pages/cooperation/cooperation.js new file mode 100644 index 0000000..43cb307 --- /dev/null +++ b/subpackages/cooperation/pages/cooperation/cooperation.js @@ -0,0 +1,182 @@ +const api = require('../../../../utils/api') + +Page({ + data: { + statusBarHeight: 20, + navBarHeight: 44, + totalNavHeight: 64, + swiperHeight: 400, + sliderIndex: 0, + sliderList: [ + + { id: 'listening', src: 'https://ai-c.maimanji.com/images/cooperation/slider_listening.png' }, + { id: 'eldercare', src: 'https://ai-c.maimanji.com/images/cooperation/slider_eldercare.png' }, + { id: 'highend', src: 'https://ai-c.maimanji.com/images/cooperation/slider_highend.png' } + ], + entryList: [ + { + id: 'entertainment', + title: '休闲娱乐', + subTitle: '精选活动 精彩人生', + icon: '/subpackages/cooperation/images/icon_entertainment.png' + }, + { + id: 'eldercare', + title: '智慧养老', + subTitle: '科技赋能 安享晚年', + icon: '/subpackages/cooperation/images/icon_eldercare.png' + } + ] + }, + + onLoad() { + const sys = wx.getSystemInfoSync(); + const menu = wx.getMenuButtonBoundingClientRect(); + const statusBarHeight = sys.statusBarHeight || 20; + const navBarHeight = menu.height + (menu.top - statusBarHeight) * 2; + this.setData({ + statusBarHeight, + navBarHeight, + totalNavHeight: statusBarHeight + navBarHeight + }); + this.loadBanners(); + this.loadEntries(); + }, + + /** + * 处理图片URL,如果是相对路径则拼接域名,并设置清晰度为85 + */ + processImageUrl(url) { + if (!url) return '' + let fullUrl = url + if (!url.startsWith('http://') && !url.startsWith('https://')) { + const baseUrl = 'https://ai-c.maimanji.com' + fullUrl = baseUrl + (url.startsWith('/') ? '' : '/') + url + } + + // 添加清晰度参数 q=85 + if (fullUrl.includes('?')) { + if (!fullUrl.includes('q=')) { + fullUrl += '&q=85' + } + } else { + fullUrl += '?q=85' + } + return fullUrl + }, + + /** + * 轮播图图片加载完成,自适应高度 + */ + onBannerLoad(e) { + if (this.data.swiperHeight !== 400) return; // 只计算一次 + const { width, height } = e.detail; + const sysInfo = wx.getSystemInfoSync(); + // 减去左右padding (24rpx * 2) - 根据 cooperation.wxss 的 .slider-card + const swiperWidth = sysInfo.windowWidth - (24 * 2 / 750 * sysInfo.windowWidth); + const ratio = width / height; + const swiperHeight = swiperWidth / ratio; + const swiperHeightRpx = swiperHeight * (750 / sysInfo.windowWidth); + + this.setData({ + swiperHeight: swiperHeightRpx + }); + }, + + /** + * 加载合作页Banner + */ + async loadBanners() { + try { + const res = await api.pageAssets.getCooperationBanners() + console.log('合作页Banner API响应:', res) + + if (res.success && res.data && res.data.length > 0) { + const sliderList = res.data.map(item => ({ + id: item.id, + src: this.processImageUrl(item.asset_url), + link: item.description // 假设描述字段存放跳转链接 + })) + this.setData({ sliderList }) + } + } catch (err) { + console.error('加载合作页Banner失败', err) + } + }, + + /** + * 加载合作页入口图标 + */ + async loadEntries() { + try { + // 合作页的入口图标与服务页的服务图标共用 service_icons 分组 + const res = await api.pageAssets.getAssets('service_icons') + console.log('合作页入口图标 API响应:', res) + + if (res.success && res.data) { + const icons = res.data + const { entryList } = this.data + + const updatedEntryList = entryList.map(item => { + // 尝试匹配图标,例如 id='medical' 对应 icons['medical'] + const iconUrl = icons[item.id] + if (iconUrl) { + return { + ...item, + icon: this.processImageUrl(iconUrl) + } + } + return item + }) + + this.setData({ entryList: updatedEntryList }) + } + } catch (err) { + console.error('加载合作页入口图标失败', err) + } + }, + + onBack() { + wx.navigateBack({ delta: 1 }); + }, + + onSwiperChange(e) { + this.setData({ sliderIndex: Number(e.detail.current || 0) }); + }, + + onTapSwiper(e) { + const index = e.currentTarget.dataset.index + const item = this.data.sliderList[index] + if (item && item.link) { + wx.navigateTo({ url: item.link }) + } + }, + + onTapEntry(e) { + const id = e.currentTarget.dataset.id; + // Find link from data + const entry = this.data.entryList.find(x => x.id === id); + if (entry && entry.link) { + wx.navigateTo({ url: entry.link }); + return; + } + + // Fallback logic if link missing but id matches known routes + const routes = { + entertainment: '/pages/entertainment-apply/entertainment-apply', + medical: '/pages/medical-apply/medical-apply', + housekeeping: '/pages/housekeeping-apply/housekeeping-apply', + eldercare: '/pages/eldercare-apply/eldercare-apply' + }; + const url = routes[id]; + if (url) { + wx.navigateTo({ url }); + } else { + wx.showToast({ title: '敬请期待', icon: 'none' }); + } + }, + + onTapOrders() { + wx.navigateTo({ url: '/pages/orders/orders' }); + } +}); diff --git a/subpackages/cooperation/pages/cooperation/cooperation.json b/subpackages/cooperation/pages/cooperation/cooperation.json new file mode 100644 index 0000000..3153ca5 --- /dev/null +++ b/subpackages/cooperation/pages/cooperation/cooperation.json @@ -0,0 +1,5 @@ +{ + "usingComponents": { + "app-icon": "/components/icon/icon" + } +} diff --git a/subpackages/cooperation/pages/cooperation/cooperation.wxml b/subpackages/cooperation/pages/cooperation/cooperation.wxml new file mode 100644 index 0000000..c1da704 --- /dev/null +++ b/subpackages/cooperation/pages/cooperation/cooperation.wxml @@ -0,0 +1,60 @@ + + + + + + 返回 + + 合作入驻 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/subpackages/cooperation/pages/cooperation/cooperation.wxss b/subpackages/cooperation/pages/cooperation/cooperation.wxss new file mode 100644 index 0000000..3cc9862 --- /dev/null +++ b/subpackages/cooperation/pages/cooperation/cooperation.wxss @@ -0,0 +1,195 @@ +.page { + min-height: 100vh; + background: #F5F7FA; + padding-bottom: env(safe-area-inset-bottom); +} + +/* 顶部导航栏已移除,改用全局 unified-header */ + +.container { + padding: 32rpx; + width: 100%; +} + +.content { + flex: 1; +} + +.slider-card { + width: 100%; + height: 342rpx; + border-radius: 32rpx; + overflow: hidden; + margin: 0 0 48rpx; + box-shadow: 0 2rpx 4rpx -2rpx rgba(0, 0, 0, 0.1), 0 2rpx 6rpx 0 rgba(0, 0, 0, 0.1); + position: relative; +} + +.slider { + width: 100%; + height: 100%; +} + +.slide { + width: 100%; + height: 100%; + position: relative; +} + +.slide-image { + width: 100%; + height: 329rpx; + margin-top: 0; +} + +.slide-mask { + position: absolute; + left: 0; + right: 0; + bottom: 13rpx; + height: 329rpx; + background: linear-gradient(0deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0) 100%); +} + +.dots { + position: absolute; + left: 0; + right: 0; + bottom: 45rpx; + display: flex; + justify-content: center; + pointer-events: none; +} + +.dot-item { + width: 40rpx; + height: 40rpx; + display: flex; + align-items: center; + justify-content: center; + margin: 0 10rpx; +} + +.dot { + width: 16rpx; + height: 16rpx; + border-radius: 999rpx; + background: rgba(255, 255, 255, 0.4); +} + +.dot-active { + background: rgba(255, 255, 255, 1); +} + +.entry-list { + display: flex; + flex-direction: column; + margin-bottom: 48rpx; + width: 100%; + align-items: stretch; +} + +.entry-btn { + width: 100%; + min-width: 100%; + height: 196rpx; + border-radius: 32rpx; + background: #FFFFFF; + box-shadow: 0 2rpx 4rpx -2rpx rgba(0, 0, 0, 0.1), 0 2rpx 6rpx 0 rgba(0, 0, 0, 0.1); + padding: 0 40rpx; + display: flex; + align-items: center; + justify-content: flex-start; + text-align: left; + box-sizing: border-box; + align-self: stretch; +} + +.entry-icon { + width: 112rpx; + height: 112rpx; + margin-right: 32rpx; +} + +.entry-texts { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + margin-right: 32rpx; +} + +.entry-title { + display: block; + font-size: 36rpx; + font-weight: 900; + color: #101828; + line-height: 56rpx; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.entry-sub { + display: block; + font-size: 28rpx; + font-weight: 400; + color: #6A7282; + line-height: 40rpx; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.entry-chevron { + width: 40rpx; + height: 40rpx; + margin-left: auto; +} + +.orders-btn { + width: 100%; + min-width: 100%; + align-self: stretch; + box-sizing: border-box; + height: 196rpx; + border-radius: 32rpx; + background: linear-gradient(180deg, rgba(176, 106, 179, 1) 0%, rgba(156, 90, 157, 1) 100%); + box-shadow: 0 8rpx 12rpx -8rpx rgba(0, 0, 0, 0.1), 0 20rpx 30rpx -6rpx rgba(0, 0, 0, 0.1); + padding: 0 40rpx; + display: flex; + align-items: center; + justify-content: flex-start; + text-align: left; + margin-bottom: 48rpx; +} + +.orders-icon { + width: 112rpx; + height: 112rpx; + margin-right: 32rpx; +} + +.orders-title { + font-size: 36rpx; + font-weight: 900; + color: #FFFFFF; + line-height: 56rpx; +} + +.orders-sub { + font-size: 28rpx; + font-weight: 400; + color: rgba(255, 255, 255, 0.8); + line-height: 40rpx; +} + +.orders-chevron { + width: 48rpx; + height: 48rpx; + margin-left: auto; +} + +.entry-btn + .entry-btn { + margin-top: 32rpx; +} diff --git a/team_page_redesign.md b/team_page_redesign.md new file mode 100644 index 0000000..9c2e01a --- /dev/null +++ b/team_page_redesign.md @@ -0,0 +1,48 @@ +# Team Page Redesign & Integration + +## Overview +The "My Team" page (`pages/team`) has been redesigned to match the new Figma specifications, including a dynamic "Guardian Member" card and a modernized member list. The data integration has been updated to use real backend APIs for team statistics and referral lists. + +## Changes + +### 1. UI Redesign (WXML & WXSS) +- **Guardian Card**: Added a visual card at the top displaying the user's membership level and team stats. + - Dynamic Title: Shows "Guardian Member", "VIP Member", etc. based on `cardType`. + - Stats: Displays "Direct Referrals" and "Team Total". +- **Member List**: + - New card style for each member. + - Shows Avatar, Name, Join Date, "First Referral" tag, and Contribution Amount. + - Handles empty states and loading states. +- **Styling**: + - Implemented purple gradient themes. + - Used `app-icon` for consistent iconography. + - Refined spacing and typography. + +### 2. Logic & Integration (JS) +- **Data Fetching**: + - `api.commission.getStats`: Fetches team statistics (`todayReferrals`, `totalReferrals`, `totalContribution`, `cardType`). + - `api.commission.getReferrals` (via direct request): Fetches the list of direct referrals. + - **Robust Mapping**: Added logic to handle potential API field variations (e.g., `user.nickname` vs `userName`, `snake_case` vs `camelCase`). +- **Navigation**: + - Added `goDetail` to navigate to `pages/commission/commission` for detailed performance breakdowns. +- **Dynamic Content**: + - `cardTitle`: Computed from `cardType` (e.g., 'guardian_card' -> '守护会员'). + +### 3. Profile Page Integration +- Updated "Customer Management" section in `pages/profile`. +- Replaced old cards with "My Team" and "Performance Data". +- "Performance Data" displays the `totalContribution` fetched from commission stats. +- Both cards navigate to the Team page for consistency. + +## API Consistency +- **Team Stats**: Matches `/api/commission?action=stats`. +- **Referral List**: Matches `/api/commission?action=referrals`. +- **Commission Details**: Links to existing `pages/commission` which handles full commission records. + +## Files Modified +- `pages/team/team.wxml` +- `pages/team/team.wxss` +- `pages/team/team.js` +- `pages/profile/profile.wxml` +- `pages/profile/profile.wxss` +- `pages/profile/profile.js` diff --git a/utils/api.js b/utils/api.js new file mode 100644 index 0000000..24e4aea --- /dev/null +++ b/utils/api.js @@ -0,0 +1,1856 @@ +/** + * API服务层 + * 封装所有后端API调用 + */ + +const config = require('../config/index') + +// ==================== 请求封装 ==================== + +/** + * 基础请求方法 + * @param {string} url - API路径 + * @param {object} options - 请求选项 + * @returns {Promise} + */ +const request = (url, options = {}) => { + return new Promise((resolve, reject) => { + const token = wx.getStorageSync(config.STORAGE_KEYS.TOKEN) + + wx.request({ + url: config.API_BASE_URL + url, + method: options.method || 'GET', + data: options.data, + timeout: config.REQUEST_TIMEOUT, + header: { + 'Authorization': token ? `Bearer ${token}` : '', + 'Content-Type': 'application/json', + // 注意:x-user-id 已废弃,后端通过 Token 验证用户身份 + ...options.header + }, + success: (res) => { + // 始终打印简短的请求和响应信息用于调试 + console.log(`[API] ${options.method || 'GET'} ${url} -> ${res.statusCode}`) + + // 如果返回的是 HTML (通常是 404 页面),打印简短提示而非全文 + const isHtml = typeof res.data === 'string' && res.data.trim().startsWith(' { + console.error(`[API Error] ${url}`, err) + // 不自动显示toast,让调用方处理错误 + reject({ + code: -1, + message: '网络错误', + errMsg: err.errMsg || 'request:fail', + error: err + }) + } + }) + }) +} + +/** + * 文件上传 + * @param {string} filePath - 本地文件路径 + * @param {string} folder - 存储目录 (avatar/image/audio) + * @returns {Promise} + */ +const uploadFile = (filePath, folder = 'uploads') => { + return new Promise((resolve, reject) => { + console.log('[uploadFile] ========== 开始上传任务 ==========') + console.log('[uploadFile] 文件路径:', filePath) + console.log('[uploadFile] 目标目录:', folder) + + // 验证目录是否合法,不合法则使用默认的 uploads + const allowedFolders = ['uploads', 'image', 'images', 'avatars', 'characters', 'audio', 'documents', 'temp', 'assets'] + if (!allowedFolders.includes(folder)) { + console.warn(`[uploadFile] 目录 "${folder}" 不在允许列表中,将使用 "uploads"`) + folder = 'uploads' + } + + const token = wx.getStorageSync(config.STORAGE_KEYS.TOKEN) + const userId = wx.getStorageSync(config.STORAGE_KEYS.USER_ID) + const deviceId = wx.getStorageSync('deviceId') || userId || 'unknown' + + // 始终包含 x-device-id,如果有 token 则包含 Authorization + const header = { + 'x-device-id': deviceId + } + + if (token) { + header['Authorization'] = `Bearer ${token}` + console.log('[uploadFile] 使用Token认证') + } else { + header['x-device-id'] = deviceId + console.log('[uploadFile] 使用DeviceId认证:', deviceId) + } + + wx.uploadFile({ + url: config.API_BASE_URL + '/upload', + filePath: filePath, + name: 'file', + formData: { folder }, + header: header, + success: (res) => { + console.log('[uploadFile] ========== 上传响应 ==========') + console.log('[uploadFile] 状态码:', res.statusCode) + console.log('[uploadFile] 响应头:', JSON.stringify(res.header)) + console.log('[uploadFile] 响应体:', res.data) + + if (res.statusCode === 200) { + try { + const data = JSON.parse(res.data) + console.log('[uploadFile] 解析后的数据:', JSON.stringify(data)) + + // 兼容两种响应格式 + if (data.code === 0 && data.data) { + console.log('[uploadFile] ✓ 上传成功 (code=0):', data.data.url) + resolve({ success: true, data: data.data }) + } else if (data.success && data.data) { + console.log('[uploadFile] ✓ 上传成功 (success=true):', data.data.url) + resolve(data) + } else { + const errMsg = data.message || '上传失败' + console.error('[uploadFile] ✗ 业务错误:', errMsg) + reject({ code: data.code || -1, message: errMsg }) + } + } catch (e) { + console.error('[uploadFile] ✗ 解析响应JSON失败:', e) + reject({ code: -1, message: '服务器响应格式错误' }) + } + } else if (res.statusCode === 500) { + let errorData = {} + try { + errorData = JSON.parse(res.data) + } catch (e) {} + + console.error('[uploadFile] ✗ 服务器内部错误 (500):', errorData) + reject({ + code: 500, + message: errorData.message || '服务器内部错误,请稍后重试', + detail: errorData + }) + } else if (res.statusCode === 413) { + console.error('[uploadFile] ✗ 文件太大 (413)') + reject({ code: 413, message: '文件太大,请选择较小的图片' }) + } else if (res.statusCode === 401 || res.statusCode === 403) { + console.error('[uploadFile] ✗ 权限错误 (401/403)') + reject({ code: res.statusCode, message: '权限不足,请重新登录' }) + } else { + console.error('[uploadFile] ✗ HTTP错误:', res.statusCode) + reject({ code: res.statusCode, message: `上传失败 (${res.statusCode})` }) + } + }, + fail: (err) => { + console.error('[uploadFile] ✗ wx.uploadFile 接口调用失败:', err) + reject({ code: -1, message: '网络连接失败,请检查网络' }) + } + }) + }) +} + +// ==================== 认证模块 API ==================== + +const auth = { + /** + * 发送短信验证码 + * @param {string} phone - 手机号 + */ + sendSms: (phone) => request('/auth/send-sms', { + method: 'POST', + data: { phone } + }), + + /** + * 手机号登录 + * @param {string} phone - 手机号 + * @param {string} code - 验证码 + */ + phoneLogin: (phone, code) => request('/auth/phone-login', { + method: 'POST', + data: { phone, code } + }), + + /** + * 微信手机号快速登录 + * @param {string} code - 微信getPhoneNumber返回的code + */ + wxPhoneLogin: (code, loginCode) => request('/auth/wx-phone-login', { + method: 'POST', + data: { code, loginCode } + }), + + /** + * 微信登录 + * @param {string} code - 微信登录code + * @param {object} userInfo - 用户信息(可选) + */ + wxLogin: (code, userInfo = null) => request('/auth/wx-login', { + method: 'POST', + data: { code, userInfo } + }), + + /** + * 获取当前用户信息 + */ + getCurrentUser: () => request('/auth/me'), + + /** + * 退出登录 + */ + logout: () => request('/auth/logout', { method: 'POST' }) +} + +// ==================== 用户模块 API ==================== + +const user = { + /** + * 获取用户资料 + */ + getProfile: () => request('/users/profile'), + + /** + * 更新用户资料 + * @param {object} data - 用户资料 + */ + updateProfile: (data) => request('/users/profile', { + method: 'PUT', + data + }), + + /** + * 获取用户余额 + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + getBalance: () => request('/user/balance'), + + /** + * 获取余额历史 + * @param {object} params - 分页参数 + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + getBalanceHistory: (params = {}) => request('/user/balance/history', { + data: params + }), + + /** + * 绑定手机号(微信授权方式) + * @param {object} data - { code: 微信授权code } + */ + bindPhone: (data) => request('/users/bindPhone', { + method: 'POST', + data: data + }), + + /** + * 获取用户记忆/档案 + * @param {string} characterId - 角色ID(可选) + */ + getMemories: (characterId = null) => { + const params = characterId ? { character_id: characterId } : {} + return request('/memory/memories', { data: params }) + }, + + /** + * 获取用户档案 + */ + getMemoryProfile: () => request('/memory/profile') +} + +// ==================== 角色模块 API ==================== + +const character = { + /** + * 获取角色列表 + * @param {object} params - 查询参数 { page, limit, gender, category } + */ + getList: (params = {}) => request('/characters', { + data: { page: 1, limit: 20, ...params } + }), + + /** + * 获取角色详情 + * @param {string} id - 角色ID + */ + getDetail: (id) => request(`/characters/${id}`), + + /** + * 随机推荐角色 + * @param {number} count - 数量 + * @param {object} options - 可选参数 + * @param {Array} options.excludeIds - 排除的角色ID列表 + * @param {string} options.gender - 性别筛选 (male/female) + */ + getRandom: (count = 6, options = {}) => { + const params = { count } + if (options.excludeIds && options.excludeIds.length > 0) { + params.excludeIds = options.excludeIds.join(',') + } + if (options.gender) { + params.gender = options.gender + } + return request('/characters/random', { data: params }) + }, + + /** + * 喜欢/取消喜欢角色 + * @param {string} id - 角色ID + */ + toggleLike: (id) => request(`/characters/${id}/like`, { + method: 'POST' + }), + + /** + * 获取喜欢的角色列表 + */ + getLikedList: () => request('/characters/liked'), + + /** + * 解锁角色 + * @param {object} data - { character_id, unlock_type: 'hearts'|'payment' } + */ + unlock: (data) => request('/characters/unlock', { + method: 'POST', + data + }), + + /** + * 检查角色是否已解锁 + * @param {string} id - 角色ID + */ + checkUnlockStatus: (id) => request(`/characters/${id}/unlock-status`, { + silent: true + }) +} + +// ==================== 聊天模块 API ==================== + +const chat = { + /** + * 发送AI聊天消息(使用简化版非流式API) + * @param {object} data - { character_id, message, conversation_id } + */ + sendMessage: (data) => request('/chat/simple', { + method: 'POST', + data + }), + + /** + * 获取聊天配额状态 + * @param {string} characterId - 角色ID + */ + getQuota: (characterId) => request('/chat/quota', { + data: { character_id: characterId }, + silent: true + }), + + /** + * 消费聊天配额 + * @param {string} characterId - 角色ID + */ + consumeQuota: (characterId) => request('/chat/quota/consume', { + method: 'POST', + data: { character_id: characterId } + }), + + /** + * 获取会话列表 + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + getConversations: () => request('/conversations', { silent: true }), + + /** + * 删除会话 + * @param {string} conversationId - 会话ID + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + deleteConversation: (conversationId) => request(`/conversations/${conversationId}`, { + method: 'DELETE' + }), + + /** + * 清空聊天记录(保留会话) + * @param {string} characterId - 角色ID + * 注意:只清空聊天记录,会话仍然显示在消息列表中 + * 使用后端提供的 /api/memory/chat-history/by-character 接口 + */ + clearChatHistory: (characterId) => request(`/memory/chat-history/by-character?character_id=${characterId}&action=clear`, { + method: 'POST' + }), + + /** + * 创建会话 + * @param {string} characterId - 角色ID + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + createConversation: (characterId) => request('/conversations', { + method: 'POST', + data: { + targetId: characterId, + targetType: 'character' + } + }), + + /** + * 获取聊天历史(通过会话ID) + * @param {string} conversationId - 会话ID + * @param {object} params - 分页参数 + */ + getChatHistory: (conversationId, params = {}) => request('/memory/chat-history', { + data: { conversation_id: conversationId, ...params } + }), + + /** + * 获取聊天历史(通过角色ID) + * @param {string} characterId - 角色ID + * @param {object} params - 分页参数 + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + getChatHistoryByCharacter: (characterId, params = {}) => { + // 手动构建查询参数字符串(微信小程序不支持 URLSearchParams) + const queryParts = [`character_id=${encodeURIComponent(characterId)}`] + + // 添加其他参数 + Object.keys(params).forEach(key => { + if (params[key] !== undefined && params[key] !== null) { + queryParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`) + } + }) + + const queryString = queryParts.join('&') + const fullUrl = `/memory/chat-history/by-character?${queryString}` + + console.log('[API] getChatHistoryByCharacter 请求URL:', fullUrl) + console.log('[API] characterId:', characterId, 'params:', params) + + return request(fullUrl, { + method: 'GET', + silent: true // 静默模式,401时不清除登录状态 + }) + }, + + /** + * 标记会话已读 + * @param {string} conversationId - 会话ID + */ + markAsRead: (conversationId) => request(`/conversations/${conversationId}/read`, { + method: 'POST', + silent: true // 静默模式,401时不弹出登录提示 + }), + + /** + * 切换聊天模式(AI/真人) + * @param {string} conversationId - 会话ID + * @param {string} mode - 模式 (ai/human) + */ + switchMode: (conversationId, mode) => request(`/conversations/${conversationId}/switch-mode`, { + method: 'POST', + data: { mode } + }), + + /** + * 发送图片消息 + * @param {object} data - { character_id, conversation_id, image_url } + */ + sendImage: (data) => request('/chat/send-image', { + method: 'POST', + data + }), + + /** + * 获取免费畅聊时间 + */ + getFreeTime: () => request('/chat/free-time', { + silent: true + }), + + /** + * 领取免费畅聊时间 + */ + claimFreeTime: () => request('/chat/free-time/claim', { + method: 'POST' + }) +} + +// ==================== 陪聊模块 API ==================== + +const companion = { + /** + * 发送陪聊消息 + * @param {object} data - { order_id, message } + */ + sendMessage: (data) => request('/companion-chat', { + method: 'POST', + data + }), + + /** + * 获取陪聊师列表 + * 返回数据包含等级信息:levelCode, levelName, textPrice, voicePrice + * @param {object} params - 查询参数 { status, gender, sortBy, page, pageSize } + */ + getList: (params = {}) => request('/companions', { + data: { page: 1, pageSize: 20, ...params } + }), + + /** + * 获取陪聊师详情 + * 返回数据包含等级信息:levelCode, levelName, textPrice, voicePrice + * @param {string} id - 陪聊师ID + */ + getDetail: (id) => request(`/companions/${id}`), + + /** + * 获取陪聊师状态(包含等级信息) + * 返回数据包含:isCompanion, companionId, levelCode, levelName, textPrice, voicePrice + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + getStatus: () => request('/companion/status'), + + /** + * 更新在线状态 + * @param {string} status - 状态 (online/busy/offline) + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + updateStatus: (status) => request('/companion/status', { + method: 'POST', + data: { status } + }), + + /** + * 获取申请状态 + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + getApplyStatus: () => request('/companion/apply'), + + /** + * 申请成为陪聊师 + * @param {object} data - 申请资料 + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + apply: (data) => request('/companion/apply', { + method: 'POST', + data + }), + + /** + * AI辅助回复 + * @param {object} data - { message, context } + */ + aiAssist: (data) => request('/companion/ai-assist', { + method: 'POST', + data + }), + + /** + * 获取工作台数据(包含等级信息) + * 返回数据包含:levelCode, levelName, textPrice, voicePrice + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + getWorkbench: () => request('/companion/workbench'), + + /** + * 获取客户列表 + * @param {object} params - 查询参数 { keyword, page, pageSize } + */ + getCustomers: (params = {}) => request('/companion/customers', { + data: params + }), + + /** + * 获取陪聊师订单列表 + * @param {object} params - 查询参数 { status, page, pageSize } + */ + getOrders: (params = {}) => request('/companion/orders', { + data: params + }), + + /** + * 获取陪聊师订单统计 + */ + getOrderStats: () => request('/companion/orders/stats'), + + /** + * 获取所有等级配置 + * 返回等级列表:[{ levelCode, levelName, textPrice, voicePrice }] + */ + getLevels: () => request('/companion/levels'), + + /** + * 获取陪聊师评价列表 + * @param {string} companionId - 陪聊师ID + * @param {object} params - 查询参数 { page, limit } + */ + getReviews: (companionId, params = {}) => request(`/companions/${companionId}/reviews`, { + data: { page: 1, limit: 10, ...params } + }), + + /** + * 点赞评价 + * @param {string} reviewId - 评价ID + */ + likeReview: (reviewId) => request(`/reviews/${reviewId}/like`, { + method: 'POST' + }), + + /** + * 陪聊师回复评价 + * @param {string} reviewId - 评价ID + * @param {string} reply - 回复内容 + */ + replyReview: (reviewId, reply) => request(`/reviews/${reviewId}/reply`, { + method: 'POST', + data: { reply } + }) +} + +// ==================== 语音合成 API ==================== + +const tts = { + /** + * 文字转语音 + * @param {object} data - { text, voice_id, character_id } + */ + synthesize: (data) => request('/tts', { + method: 'POST', + data + }), + + /** + * 查询TTS状态 + * @param {string} taskId - 任务ID + */ + getStatus: (taskId) => request('/tts/status', { + data: { task_id: taskId } + }) +} + +// ==================== 语音识别 API ==================== + +const speech = { + /** + * 语音识别 - 将语音转换为文字 + * @param {object} data - { audio: base64编码的音频, format: 音频格式(mp3/wav/pcm) } + * @returns {Promise<{success: boolean, data: {text: string}}>} + */ + recognize: (data) => request('/speech/recognize', { + method: 'POST', + data + }) +} + +// ==================== 支付模块 API ==================== + +const payment = { + /** + * 获取充值套餐 + */ + getPackages: () => request('/payment/packages'), + + /** + * 创建充值订单 + * @param {object} data - { packageId, amount, paymentMethod } + */ + createRechargeOrder: (data) => request('/payment/recharge', { + method: 'POST', + data + }), + + /** + * 创建VIP订单 + * @param {object} data - { planId, duration, paymentMethod } + */ + createVipOrder: (data) => request('/payment/vip', { + method: 'POST', + data + }), + + /** + * 获取支付参数(统一下单) + * @param {object} data - { orderId, orderType } + */ + prepay: (data) => request('/payment/prepay', { + method: 'POST', + data + }), + + /** + * 查询订单状态 + * @param {string} orderId - 订单ID + * @param {object} params - 查询参数 { confirm: 1 } + */ + queryOrder: (orderId, params = {}) => request(`/payment/orders/${orderId}`, { + method: 'GET', + data: params + }), + + /** + * 取消订单 + * @param {string} orderId - 订单ID + */ + cancelOrder: (orderId) => request(`/payment/orders/${orderId}/cancel`, { + method: 'POST' + }), + + /** + * 测试模式充值 - 直接增加爱心余额(仅测试环境使用) + * @param {object} data - 充值参数 + * @param {number} data.amount - 充值爱心数量 + * @param {string} data.package_name - 套餐名称(可选) + */ + testRecharge: (data) => request('/payment/test-recharge', { + method: 'POST', + data + }), + + /** + * 测试模式支付 - 直接标记订单为已支付(仅测试环境使用) + * @param {object} data - 支付参数 + * @param {string} data.orderId - 订单ID + */ + testPay: (data) => request('/payment/test-pay', { + method: 'POST', + data + }), + + /** + * 创建统一支付订单 + * @param {object} data - 订单参数 + * @param {string} data.orderType - 订单类型: recharge|vip|companion_chat|agent_purchase|identity_card + * @param {number} data.amount - 支付金额 + * @param {number} data.rechargeValue - 充值金额(充值订单) + * @param {string} data.vipType - VIP类型: month|quarter|year(VIP订单) + * @param {string} data.agentId - 智能体ID(智能体订单) + * @param {string} data.companionId - 陪聊师ID(陪聊订单) + * @param {number} data.duration - 陪聊时长(陪聊订单) + * @param {string} data.cardType - 身份卡类型: basic|premium(身份卡订单) + * @param {string} data.referralCode - 推荐码(可选) + * @param {string} data.promotionLinkCode - 推广链接码(可选) + * @param {string} data.confirm - 是否主动确认支付状态 (1) + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + createUnifiedOrder: (data) => request('/payment/unified-order', { + method: 'POST', + data + }), + + /** + * 创建支付订单(兼容旧接口) + * @param {object} data - { package_id, payment_method } + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + createOrder: (data) => request('/payment/unified-order', { + method: 'POST', + data + }), + + /** + * 获取订单列表 + * @param {object} params - 查询参数 + * @param {string} params.orderType - 订单类型(可选) + * @param {string} params.status - 订单状态(可选) + * @param {number} params.page - 页码 + * @param {number} params.pageSize - 每页数量 + * @param {string} params.confirm - 是否主动确认支付状态 (1) + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + getOrders: (params = {}) => request('/payment/unified-order', { + data: { page: 1, pageSize: 20, ...params } + }), + + /** + * 获取订单详情 + * @param {string} orderId - 订单ID + * @param {object} params - { confirm: 1 } + */ + getOrderDetail: (orderId, params = {}) => request('/payment/unified-order', { + data: { orderId, ...params } + }), + + /** + * 查询支付状态 + * @param {string} orderId - 订单ID + * @param {object} params - { confirm: 1 } + */ + checkStatus: (orderId, params = {}) => request('/payment/unified-order', { + data: { orderId, ...params } + }), + + /** + * 使用爱心值兑换套餐 + * @param {object} data - 兑换参数 + * @param {string} data.packageId - 套餐ID (first/month/quarter/year) + * @param {number} data.heartCost - 需要的爱心值数量 + */ + exchangeWithHeart: (data) => request('/payment/exchange-with-heart', { + method: 'POST', + data + }) +} + +// ==================== 智能体商品 API ==================== + +const agentProduct = { + /** + * 获取商品列表 + * @param {object} params - 查询参数 + * @param {string} params.status - 状态(默认active) + * @param {number} params.page - 页码 + * @param {number} params.pageSize - 每页数量 + */ + getList: (params = {}) => request('/agent-products', { + data: { status: 'active', page: 1, pageSize: 20, ...params } + }), + + /** + * 获取商品详情 + * @param {string} id - 商品ID + */ + getDetail: (id) => request(`/agent-products/${id}`), + + /** + * 购买智能体 + * @param {string} id - 商品ID + * @param {object} options - 可选参数 + * @param {string} options.referralCode - 推荐码 + * @param {string} options.promotionLinkCode - 推广链接码 + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + purchase: (id, options = {}) => request(`/agent-products/${id}/purchase`, { + method: 'POST', + data: options + }), + + /** + * 获取用户已购智能体列表 + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + getPurchased: () => request('/user/purchased-agents'), + + /** + * 检查是否有权访问某角色 + * @param {string} characterId - 角色ID + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + checkAccess: (characterId) => request('/user/purchased-agents', { + data: { action: 'checkAccess', characterId } + }), + + /** + * 获取可访问的角色ID列表 + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + getAccessibleIds: () => request('/user/purchased-agents', { + data: { action: 'accessibleIds' } + }) +} + +// ==================== 页面素材 API ==================== + +const pageAssets = { + /** + * 获取通用素材配置 + * @param {string} group - 素材分组 + */ + getAssets: (group = null) => { + const url = group ? `/page-assets?group=${group}` : '/page-assets' + return request(url) + }, + + /** + * 获取服务页在线Banner列表 + */ + getServiceBanners: () => request('/page-assets/service-banners'), + + /** + * 获取娱乐页在线Banner列表 + */ + getEntertainmentBanners: () => request('/page-assets/entertainment-banners'), + + /** + * 获取合作入驻页在线Banner列表 + */ + getCooperationBanners: () => request('/page-assets/cooperation-banners') +} + +// ==================== 推广系统 API ==================== + +const promotion = { + /** + * 创建推广链接 + * @param {object} data - 链接参数 + * @param {string} data.targetType - 目标类型: agent|vip|recharge|general + * @param {string} data.targetId - 目标ID(智能体推广时需要) + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + createLink: (data) => request('/promotion', { + method: 'POST', + data + }), + + /** + * 获取推广链接列表 + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + getLinks: () => request('/promotion'), + + /** + * 获取推广统计 + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + getStats: () => request('/promotion', { + data: { action: 'stats' } + }), + + /** + * 获取链接详情 + * @param {string} linkId - 链接ID + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + getLinkDetail: (linkId) => request('/promotion', { + data: { action: 'detail', linkId } + }), + + /** + * 获取分销商排行榜 + * @param {object} params - 查询参数 + * @param {string} params.period - 周期: day|week|month|all + * @param {number} params.limit - 返回数量 + */ + getRanking: (params = {}) => request('/promotion', { + data: { action: 'ranking', period: 'month', limit: 10, ...params } + }), + + /** + * 获取分享配置 + * @param {string} pageKey - 页面标识: index|promote 等 + * 返回: { title, desc, path, imageUrl } + */ + getShareConfig: (pageKey) => request('/promotion/share-config', { + data: { page: pageKey } + }), + + /** + * 记录分享行为 + * @param {object} data - 分享参数 + * @param {string} data.type - 分享类型: app_message|timeline + * @param {string} data.page - 页面路径 + * @param {string} data.referralCode - 推荐码(可选) + */ + recordShare: (data) => request('/promotion', { + method: 'POST', + data: { action: 'recordShare', ...data } + }) +} + +// ==================== 订单模块 API ==================== + +const order = { + /** + * 获取订单列表 + * @param {object} params - { type, status, page, limit } + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + getList: (params = {}) => request('/orders', { + data: { page: 1, pageSize: 20, ...params } + }), + + /** + * 获取订单详情 + * @param {string} id - 订单ID + */ + getDetail: (id) => request(`/orders/${id}`), + + /** + * 创建陪聊订单 + * @param {object} data - { companion_id, duration, message } + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + createCompanionOrder: (data) => request('/orders/companion', { + method: 'POST', + data + }), + + /** + * 取消订单 + * @param {string} id - 订单ID + * @param {string} reason - 取消原因 + */ + cancel: (id, reason = '') => request(`/orders/${id}/cancel`, { + method: 'POST', + data: { reason } + }), + + /** + * 评价订单 + * @param {string} id - 订单ID + * @param {object} data - { rating, content, tags, isAnonymous } + */ + review: (id, data) => request(`/orders/${id}/review`, { + method: 'POST', + data + }), + + /** + * 获取订单大厅(陪聊师用) + */ + getHall: () => request('/orders/hall'), + + /** + * 接受订单 + * @param {string} id - 订单ID + */ + accept: (id) => request(`/orders/${id}/accept`, { method: 'POST' }), + + /** + * 拒绝订单 + * @param {string} id - 订单ID + * @param {string} reason - 拒绝原因 + */ + reject: (id, reason) => request(`/orders/${id}/reject`, { + method: 'POST', + data: { reason } + }), + + /** + * 开始服务 + * @param {string} id - 订单ID + */ + startService: (id) => request(`/orders/${id}/start-service`, { method: 'POST' }), + + /** + * 结束服务 + * @param {string} id - 订单ID + */ + endService: (id) => request(`/orders/${id}/end-service`, { method: 'POST' }) +} + + +// ==================== 佣金与提现 API ==================== + +const commission = { + /** + * 获取佣金统计 + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + getStats: () => request('/commission', { data: { action: 'stats' } }), + + /** + * 获取佣金记录 + * @param {object} params - 分页参数 { page, pageSize } + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + getRecords: (params = {}) => request('/commission', { + data: { action: 'records', ...params } + }), + + /** + * 获取推荐列表 + * @param {object} params - 分页参数 { page, pageSize } + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + getReferrals: (params = {}) => request('/commission', { + data: { action: 'referrals', ...params } + }), + + /** + * 获取提现记录 + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + getWithdrawals: () => request('/commission', { + data: { action: 'withdrawals' } + }), + + /** + * 绑定推荐码 + * @param {string} referralCode - 推荐码 + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + bindReferral: (referralCode) => request('/commission', { + method: 'POST', + data: { action: 'bindReferral', referralCode } + }), + + /** + * 申请提现 + * @param {object} data - 提现参数 + * @param {number} data.amount - 提现金额 + * @param {string} data.withdrawType - 提现方式: wechat|alipay|bank + * @param {object} data.accountInfo - 账户信息 { name, account } + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + withdraw: (data) => request('/commission', { + method: 'POST', + data: { action: 'withdraw', ...data } + }) +} + +// ==================== 交易流水 API ==================== + +const transaction = { + /** + * 获取交易流水 + * @param {object} params - 查询参数 + * @param {string} params.type - 交易类型(可选) + * @param {string} params.status - 状态(可选) + * @param {number} params.page - 页码 + * @param {number} params.pageSize - 每页数量 + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + getList: (params = {}) => request('/user/transactions', { + data: { page: 1, pageSize: 20, ...params } + }), + + /** + * 获取余额统计 + * 注意:后端通过 Token 验证用户身份,不再需要传递 userId + */ + getStats: () => request('/user/transactions', { + data: { action: 'stats' } + }) +} + +const withdraw = { + /** + * 申请提现 + * @param {object} data - { amount, account_type, account_info } + */ + apply: (data) => request('/withdraw/apply', { + method: 'POST', + data + }), + + /** + * 获取提现记录 + * @param {object} params - 分页参数 + */ + getRecords: (params = {}) => request('/withdraw/records', { + data: params + }) +} + +// ==================== 社交广场 API ==================== + +const post = { + /** + * 获取动态列表 + * @param {object} params - { page, limit, type } + */ + getList: (params = {}) => request('/posts', { + data: { page: 1, limit: 20, ...params } + }), + + /** + * 发布动态 + * @param {object} data - { content, images } + */ + create: (data) => request('/posts', { + method: 'POST', + data + }), + + /** + * 获取动态详情 + * @param {string} id - 动态ID + */ + getDetail: (id) => request(`/posts/${id}`), + + /** + * 删除动态 + * @param {string} id - 动态ID + */ + delete: (id) => request(`/posts/${id}`, { method: 'DELETE' }), + + /** + * 点赞/取消点赞 + * @param {string} id - 动态ID + */ + toggleLike: (id) => request(`/posts/${id}/like`, { method: 'POST' }), + + /** + * 发表评论 + * @param {string} postId - 动态ID + * @param {string} content - 评论内容 + */ + addComment: (postId, content) => request(`/posts/${postId}/comments`, { + method: 'POST', + data: { content } + }), + + /** + * 删除评论 + * @param {string} postId - 动态ID + * @param {string} commentId - 评论ID + */ + deleteComment: (postId, commentId) => request(`/posts/${postId}/comments/${commentId}`, { + method: 'DELETE' + }) +} + +// ==================== 背包道具 API ==================== + +const backpack = { + /** + * 获取背包物品 + */ + getItems: () => request('/backpack'), + + /** + * 使用物品 + * @param {string} itemId - 物品ID + */ + useItem: (itemId) => request('/backpack/use', { + method: 'POST', + data: { item_id: itemId } + }), + + /** + * 赠送礼物 + * @param {object} data - { item_id, target_id, target_type, quantity } + */ + giftItem: (data) => request('/backpack/gift', { + method: 'POST', + data + }), + + /** + * 装备道具 + * @param {string} itemId - 物品ID + */ + equipItem: (itemId) => request('/backpack/equip', { + method: 'POST', + data: { item_id: itemId } + }) +} + +// ==================== 商城 API ==================== + +const shop = { + /** + * 获取商城物品 + * @param {object} params - { category } + */ + getItems: (params = {}) => request('/shop/items', { + data: params + }), + + /** + * 购买物品 + * @param {object} data - { item_id, quantity } + */ + buyItem: (data) => request('/shop/buy', { + method: 'POST', + data + }) +} + +// ==================== 客服系统 API ==================== + +const customerService = { + /** + * 创建咨询 (开启新会话) + * @param {object} data - { category, content, userName, userPhone, subject, guestId } + */ + create: (data) => request('/customer-service', { + method: 'POST', + data: { action: 'create', ...data } + }), + + /** + * 发送回复消息 + * @param {object} data - { ticketId, content, userName } + */ + reply: (data) => request('/customer-service', { + method: 'POST', + data: { action: 'reply', ...data } + }), + + /** + * 获取咨询列表 + * @param {string} guestId - 访客唯一ID + */ + getList: (guestId) => request(`/customer-service?guestId=${guestId}`), + + /** + * 获取咨询详情及消息记录 + * @param {string} ticketId - 工单ID + */ + getDetail: (ticketId) => request(`/customer-service?ticketId=${ticketId}`) +} + +// ==================== 系统设置 API ==================== + +const settings = { + /** + * 获取用户设置 + */ + get: () => request('/settings'), + + /** + * 更新用户设置 + * @param {object} data - 设置数据 + */ + update: (data) => request('/settings', { + method: 'PUT', + data + }), + + /** + * 获取黑名单 + */ + getBlacklist: () => request('/settings/blacklist'), + + /** + * 添加黑名单 + * @param {string} userId - 用户ID + */ + addToBlacklist: (userId) => request('/settings/blacklist', { + method: 'POST', + data: { user_id: userId } + }), + + /** + * 移除黑名单 + * @param {string} userId - 用户ID + */ + removeFromBlacklist: (userId) => request(`/settings/blacklist/${userId}`, { + method: 'DELETE' + }), + + /** + * 清除缓存 + */ + clearCache: () => request('/settings/clear-cache', { method: 'POST' }), + + /** + * 检查更新 + */ + checkUpdate: () => request('/settings/check-update'), + + /** + * 提交反馈 + * @param {object} data - { type, content, contact } + */ + submitFeedback: (data) => request('/settings/feedback', { + method: 'POST', + data + }) +} + +// ==================== 公共接口 API ==================== + +const common = { + /** + * 获取审核状态 + * 1 表示开启(审核中),0 表示关闭(正式环境) + */ + getAuditStatus: () => request('/common/audit-status'), + + /** + * 获取品牌配置 + * 返回品牌故事、介绍等内容 + */ + getBrandConfig: () => request('/public/brand-config'), + + /** + * 获取公告列表 + * 返回启用的公告列表(按 sortOrder 升序排列) + */ + getNotices: () => request('/public/entertainment/notices'), + + /** + * 获取公告详情 + * @param {number} id - 公告ID + */ + getNoticeDetail: (id) => request(`/public/entertainment/notices/${id}`) +} + +// ==================== 协议模块 API ==================== + +const agreement = { + /** + * 获取协议内容 + * @param {string} code - 协议代码 (phone-guide, user-agreement, privacy-policy) + */ + get: (code) => request(`/agreements/${code}`) +} + +// ==================== 图片回复话术 API ==================== + +const imageReply = { + /** + * 获取随机图片回复话术 + * 用于用户发送图片时,AI返回预设的话术回复 + */ + getRandom: () => request('/image-reply/random') +} + +// ==================== 主动推送消息 API ==================== + +const proactiveMessage = { + /** + * 获取待推送消息 + * 返回AI角色主动发送的消息列表(跟进消息和打招呼消息) + * 只返回未读(read_at IS NULL)的消息 + */ + getPending: () => request('/proactive-messages/pending', { silent: true }), + + /** + * 标记消息已读 + * @param {object} data - 参数(二选一) + * @param {string} data.character_id - 按角色ID标记(推荐) + * @param {Array} data.message_ids - 按消息ID列表标记 + */ + markAsRead: (data) => request('/proactive-messages/read', { + method: 'POST', + data, + silent: true + }) +} + +// ==================== 活动模块 API ==================== + +const activity = { + /** + * 获取活动列表 + * @param {object} params - 查询参数 + * @param {string} params.tab - 活动标签:featured(精选)、newbie(新手) + * @param {string} params.category - 活动分类:city(同城)、outdoor(户外)、travel(旅行)、featured(精选) + * @param {string} params.city - 城市筛选 + * @param {string} params.keyword - 关键词搜索 + * @param {string} params.priceType - 价格类型:free(免费)、paid(付费) + * @param {string} params.dateFrom - 开始日期筛选 (YYYY-MM-DD) + * @param {string} params.dateTo - 结束日期筛选 (YYYY-MM-DD) + * @param {string} params.sortBy - 排序方式:date(日期)、likes(点赞数)、participants(参与人数) + * @param {number} params.page - 页码 + * @param {number} params.limit - 每页数量 + */ + getList: (params = {}) => request('/entertainment/activities', { data: params }), + + /** + * 获取活动详情 + * @param {string} id - 活动ID + */ + getDetail: (id) => request(`/entertainment/activities/${id}`), + + /** + * 获取活动参与者列表 + * @param {string} id - 活动ID + * @param {object} params - 分页参数 { page, limit } + */ + getParticipants: (id, params = {}) => request(`/entertainment/activities/${id}/registrations`, { + data: params + }), + /** + * 报名活动 + * @param {string} id - 活动ID + */ + signup: (id) => request(`/entertainment/activities/${id}/signup`, { + method: 'POST' + }), + + /** + * 取消报名 + * @param {string} id - 活动ID + */ + cancelSignup: (id) => request(`/entertainment/activities/${id}/signup`, { + method: 'DELETE' + }), + + /** + * 点赞/收藏活动 + * @param {string} id - 活动ID + */ + favorite: (id) => request(`/entertainment/activities/${id}/like`, { + method: 'POST' + }), + + /** + * 取消点赞/收藏活动 + * @param {string} id - 活动ID + */ + unfavorite: (id) => request(`/entertainment/activities/${id}/like`, { + method: 'POST' + }), + + /** + * 取消点赞/取消点赞活动 + * @param {string} id - 活动ID + */ + toggleLike: (id) => request(`/entertainment/activities/${id}/like`, { + method: 'POST' + }), + + /** + * 获取我的报名列表 + * @param {object} params - { status, page, limit } + */ + getMyRegistrations: (params = {}) => request('/my-registrations', { data: params }) +} + +// ==================== 兴趣搭子 API ==================== + +const interest = { + /** + * 获取兴趣分类列表 + */ + getCategories: () => request('/interest-categories'), + + /** + * 获取兴趣搭子列表 + * @param {object} params - 查询参数 + * @param {number} params.page - 页码 + * @param {number} params.limit - 每页数量 + * @param {string} params.city - 城市筛选 + * @param {string} params.province - 省份筛选 + * @param {number} params.category_id - 分类ID筛选 + */ + getList: (params = {}) => request('/interest-partners', { data: params }), + + /** + * 获取兴趣搭子详情 + * @param {number} id - 搭子ID + */ + getDetail: (id) => request(`/interest-partners/${id}`) +} + +// ==================== 文娱页面 API ==================== + +const entertainment = { + /** + * 获取首页数据(包含轮播图、分类、公告、精选活动、新手活动) + * @param {string} city - 城市名称(可选) + */ + getHome: (city = null) => { + const params = city ? { city } : {} + return request('/entertainment/home', { data: params }) + }, + + /** + * 获取轮播Banner + */ + getBanners: () => request('/entertainment/banners'), + + /** + * 获取滚动公告 + */ + getNotices: () => request('/entertainment/notices'), + + /** + * 每日签到 + */ + checkin: () => request('/entertainment/checkin', { + method: 'POST' + }), + + /** + * 获取签到记录 + * @param {string} month - 月份 (YYYY-MM) + */ + getCheckinRecords: (month) => request('/entertainment/checkin/records', { + data: { month } + }), + + /** + * 爱心传递 + * @param {object} data - 传递信息 + */ + transferLove: (data) => request('/entertainment/love/transfer', { + method: 'POST', + data + }), + + /** + * 获取爱心榜 + * @param {string} type - 榜单类型 (day/week/month) + * @param {number} limit - 返回数量 + */ + getLoveRanking: (type = 'day', limit = 10) => + request('/entertainment/love/ranking', { + data: { type, limit } + }) +} + +// ==================== 爱心值系统 API ==================== + +const lovePoints = { + /** + * 记录分享获得爱心值 + */ + share: () => request('/love-points/share', { + method: 'POST' + }), + + /** + * 登录获得爱心值(B自己+100,分享人也可获得+100) + * 后端会检查是否有inviter参数,给分享人也+100 + */ + login: () => request('/love-points/login', { + method: 'POST' + }), + + /** + * 获取邀请码 + */ + getInvitationCode: () => request('/love-points/invitation-code'), + + /** + * 完善资料获得爱心值 + */ + profileComplete: () => request('/love-points/profile-complete', { + method: 'POST' + }), + + /** + * 获取爱心值流水记录 + * @param {object} params - 查询参数 { page, limit } + */ + getTransactions: (params = {}) => request('/love-points/transactions', { + data: { limit: 100, ...params } + }), + + /** + * 检查注册奖励领取资格 + */ + checkRegistrationReward: () => request('/love-points/registration-reward/check'), + + /** + * 领取注册奖励 + */ + claimRegistrationReward: () => request('/love-points/registration-reward', { + method: 'POST' + }), + + /** + * 检查 GF100 弹窗领取资格 + */ + checkGf100Status: () => request('/love-points/gf100'), + + /** + * 领取 GF100 奖励 + */ + claimGf100: () => request('/love-points/gf100', { + method: 'POST' + }) +} + +const loveExchange = { + /** + * 获取可兑换选项(包含当前爱心值余额) + */ + getOptions: () => request('/love-exchange/options'), + + /** + * 使用爱心值兑换会员 + * @param {object} data - { package_id, love_cost } + */ + exchangeVip: (data) => request('/love-exchange/vip', { + method: 'POST', + data + }) +} + +const gifts = { + /** + * 获取礼品列表 + * @param {object} params - 查询参数 { category, page, limit } + */ + getList: (params = {}) => request('/gifts', { + data: params + }), + + /** + * 获取礼品详情 + * @param {string} id - 礼品ID + */ + getDetail: (id) => request(`/gifts/${id}`), + + /** + * 兑换礼品 + * @param {object} data - { giftId, shippingInfo: { name, phone, address } } + */ + exchange: (data) => request('/gifts/exchange', { + method: 'POST', + data + }), + + /** + * 获取我的兑换记录 + * @param {object} params - 查询参数 { page, limit } + */ + getMyExchanges: (params = {}) => request('/gifts/my-exchanges', { + data: { limit: 50, ...params } + }) +} + +// ==================== 推荐关系 API ==================== + +const referral = { + /** + * 绑定推荐关系 + * @param {object} data - 绑定参数 + * @param {string} data.userId - 用户ID + * @param {string} data.referralCode - 推荐码 + */ + bind: (data) => request('/referral/bind', { + method: 'POST', + data + }), + + /** + * 保存待绑定推荐码 + * @param {object} data - 参数 + * @param {string} data.userId - 用户ID + * @param {string} data.referralCode - 推荐码 + */ + savePending: (data) => request('/referral/pending', { + method: 'POST', + data + }), + + /** + * 获取待绑定推荐码 + * @param {string} userId - 用户ID + */ + getPending: (userId) => request('/referral/pending', { + data: { userId } + }) +} + +// ==================== 快乐学堂 API ==================== + +const happySchool = { + /** + * 获取分类列表 + */ + getCategories: () => request('/xinban/categories'), + + /** + * 获取文章列表 + * @param {object} params - 查询参数 + * @param {number} params.categoryId - 分类ID + * @param {string} params.keyword - 关键词搜索 + * @param {number} params.page - 页码 + * @param {number} params.limit - 每页数量 + */ + getArticles: (params = {}) => request('/xinban/articles', { + data: { page: 1, limit: 20, ...params } + }), + + /** + * 获取文章详情 + * @param {number} id - 文章ID + */ + getArticleDetail: (id) => request(`/xinban/articles/${id}`), + + /** + * 点赞/取消点赞文章 + * @param {number} id - 文章ID + */ + toggleLike: (id) => request(`/xinban/articles/${id}/like`, { + method: 'POST' + }), + + /** + * 记录阅读 + * @param {number} id - 文章ID + * @param {number} readDuration - 阅读时长(秒) + */ + recordRead: (id, readDuration = 0) => request(`/xinban/articles/${id}/read`, { + method: 'POST', + data: { readDuration } + }) +} + +// ==================== 导出所有API ==================== + +module.exports = { + // 基础方法 + request, + uploadFile, + + // 模块API + auth, + user, + character, + chat, + companion, + tts, + speech, + payment, + agentProduct, + pageAssets, + promotion, + order, + commission, + transaction, + withdraw, + post, + backpack, + shop, + customerService, + settings, + common, + agreement, + imageReply, + proactiveMessage, + + // 娱乐页面相关API + activity, + interest, + entertainment, + + // 爱心值系统API + lovePoints, + loveExchange, + gifts, + + // 推荐关系API + referral, + + // 快乐学堂API + happySchool +} diff --git a/utils/assets.js b/utils/assets.js new file mode 100644 index 0000000..1447ff4 --- /dev/null +++ b/utils/assets.js @@ -0,0 +1,193 @@ +/** + * 页面素材管理工具类 + * 用于小程序获取和缓存后台配置的图片资源 + */ + +const config = require('../config/index'); +const API_BASE_URL = config.API_BASE_URL.replace('/api', ''); // 移除 /api 后缀 +const CACHE_KEY = 'pageAssets'; +const CACHE_DURATION = 30 * 60 * 1000; // 30分钟缓存 + +/** + * 获取页面素材配置 + * @param {string|null} group - 素材分组名称,不传则获取全部 + * @param {boolean} forceRefresh - 是否强制刷新,忽略缓存 + * @returns {Promise} 素材配置对象 + */ +async function getPageAssets(group = null, forceRefresh = false) { + try { + // 1. 如果不强制刷新,先尝试从缓存读取 + if (!forceRefresh) { + const cached = getCachedAssets(); + if (cached) { + return group ? cached[group] : cached; + } + } + + // 2. 从服务器获取 + const url = group + ? `${API_BASE_URL}/api/page-assets?group=${group}` + : `${API_BASE_URL}/api/page-assets`; + + const res = await wx.request({ + url, + method: 'GET', + timeout: 10000 + }); + + if (res.statusCode === 200 && res.data.success) { + // 3. 更新缓存(只有获取全部时才缓存) + if (!group) { + setCachedAssets(res.data.data); + } + + return res.data.data; + } + + // 4. 请求失败,返回默认配置 + console.warn('获取素材配置失败,使用默认配置'); + return getDefaultAssets(group); + + } catch (error) { + console.error('获取素材配置异常:', error); + return getDefaultAssets(group); + } +} + +/** + * 从缓存读取素材配置 + */ +function getCachedAssets() { + try { + const cached = wx.getStorageSync(CACHE_KEY); + if (cached && cached.timestamp && cached.data) { + if (Date.now() - cached.timestamp < CACHE_DURATION) { + return cached.data; + } + } + return null; + } catch (error) { + console.error('读取缓存失败:', error); + return null; + } +} + +/** + * 保存素材配置到缓存 + */ +function setCachedAssets(data) { + try { + wx.setStorageSync(CACHE_KEY, { + data, + timestamp: Date.now() + }); + } catch (error) { + console.error('保存缓存失败:', error); + } +} + +/** + * 清除素材缓存 + */ +function clearAssetsCache() { + try { + wx.removeStorageSync(CACHE_KEY); + console.log('素材缓存已清除'); + } catch (error) { + console.error('清除缓存失败:', error); + } +} + +/** + * 预加载素材配置(建议在 app.js 的 onLaunch 中调用) + */ +async function preloadAssets() { + try { + await getPageAssets(null, false); + console.log('素材配置预加载完成'); + } catch (error) { + console.error('素材配置预加载失败:', error); + } +} + +/** + * 获取默认素材配置(降级方案 - 使用CDN URL) + */ +function getDefaultAssets(group = null) { + const cdnBase = 'https://ai-c.maimanji.com/images' + const defaults = { + banners: { + companion_banner: `${cdnBase}/Header-banner.png`, + service_banner: `${cdnBase}/service-banner-1.png`, + home_banner: `${cdnBase}/Header-banner.png`, + medical_banner: `${cdnBase}/service-banner-2.png`, + housekeeping_banner: `${cdnBase}/service-banner-3.png`, + eldercare_banner: `${cdnBase}/service-banner-4.png`, + service_banner_5: `${cdnBase}/service-banner-5.png`, + service_banner_6: `${cdnBase}/service-banner-6.png`, + cooperation_banner: `${cdnBase}/Header-banner.png`, + }, + entries: { + entry_1: `${cdnBase}/pb01.png`, + entry_2: `${cdnBase}/pb02.png`, + entry_3: `${cdnBase}/pb03.png`, + entry_4: `${cdnBase}/pb04.png`, + }, + icons: { + consult_button: `${cdnBase}/btn-text-consult.png`, + gift: `${cdnBase}/icon-gift.png`, + location: `${cdnBase}/icon-location.png`, + }, + service_icons: { + medical: `${cdnBase}/icon-medical.png`, + flow: `${cdnBase}/icon-flow.png`, + advantage: `${cdnBase}/icon-advantage.png`, + professional: `${cdnBase}/icon-professional.png`, + safe: `${cdnBase}/icon-safe.png`, + efficient: `${cdnBase}/icon-efficient.png`, + care: `${cdnBase}/icon-care.png`, + }, + status_icons: { + pending: `${cdnBase}/icon-pending.png`, + success: `${cdnBase}/icon-success.png`, + rejected: `${cdnBase}/icon-rejected.png`, + }, + empty_states: { + orders: `${cdnBase}/empty-orders.png`, + messages: `${cdnBase}/empty-messages.png`, + }, + defaults: { + avatar: `${cdnBase}/default-avatar.png`, + placeholder: '/images/placeholder.jpg', + }, + tabbar: { + listen: `${cdnBase}/tab-listen.png`, + listen_active: `${cdnBase}/tab-listen-active.png`, + companion: `${cdnBase}/tab-companion.png`, + companion_active: `${cdnBase}/tab-companion-active.png`, + service: `${cdnBase}/tab-service.png`, + service_active: `${cdnBase}/tab-service-active.png`, + message: `${cdnBase}/tab-message.png`, + message_unread: `${cdnBase}/tab-message-nodot.png`, + message_active: `${cdnBase}/tab-message-active.png`, + profile: `${cdnBase}/tab-profile.png`, + profile_active: `${cdnBase}/tab-profile-active.png`, + square: `${cdnBase}/tab-compass.png`, + square_active: `${cdnBase}/tab-compass-active.png`, + }, + workbench: { + orders: `${cdnBase}/icon-orders.png`, + customers: `${cdnBase}/icon-customers.png`, + withdraw: `${cdnBase}/icon-withdraw.png`, + commission: `${cdnBase}/icon-commission.png`, + }, + }; + + return group ? defaults[group] : defaults; +} + +module.exports = { + getPageAssets, + clearAssetsCache, + preloadAssets +}; diff --git a/utils/auth.js b/utils/auth.js new file mode 100644 index 0000000..ece4f29 --- /dev/null +++ b/utils/auth.js @@ -0,0 +1,464 @@ +/** + * 登录认证工具类 + * 实现30天免登录功能(实际由后端Token有效期控制,当前为7天) + * + * 功能: + * - 本地登录状态检查 + * - 服务端Token验证 + * - 微信手机号快速登录 + * - 登录状态持久化 + */ + +const config = require('../config/index') +const api = require('./api') + +// 存储键名 +const TOKEN_KEY = config.STORAGE_KEYS.TOKEN +const USER_KEY = config.STORAGE_KEYS.USER_INFO +const USER_ID_KEY = config.STORAGE_KEYS.USER_ID +const TOKEN_EXPIRY_KEY = config.STORAGE_KEYS.TOKEN_EXPIRY + +/** + * 检查是否已登录(本地验证) + * 仅检查本地存储,不发起网络请求 + * @returns {boolean} 是否已登录 + */ +function isLoggedIn() { + const token = wx.getStorageSync(TOKEN_KEY) + const expiry = wx.getStorageSync(TOKEN_EXPIRY_KEY) + + if (!token) return false + + // 如果有过期时间,检查是否过期 + if (expiry) { + return new Date(expiry) > new Date() + } + + // 没有过期时间但有token,认为已登录(兼容旧数据) + return true +} + +/** + * 获取本地保存的用户信息 + * @returns {object|null} 用户信息 + */ +function getLocalUserInfo() { + return wx.getStorageSync(USER_KEY) || null +} + +/** + * 获取登录Token + * @returns {string|null} Token + */ +function getToken() { + return wx.getStorageSync(TOKEN_KEY) || null +} + +/** + * 获取用户ID + * @returns {string|null} 用户ID + */ +function getUserId() { + return wx.getStorageSync(USER_ID_KEY) || null +} + +/** + * 验证登录状态(服务端验证) + * 调用后端API验证Token是否有效 + * @returns {Promise<{valid: boolean, user?: object, expired?: boolean, error?: any}>} + */ +async function verifyLogin() { + const token = getToken() + if (!token) { + return { valid: false } + } + + try { + const res = await api.auth.getCurrentUser() + + if (res.success && res.data) { + // 更新本地用户信息 + saveUserInfo(res.data, token) + return { valid: true, user: res.data } + } else { + // Token无效,清除本地数据 + clearLoginInfo() + return { valid: false, expired: true } + } + } catch (error) { + console.error('验证登录失败:', error) + + // 401错误表示Token过期 + if (error.code === 401) { + clearLoginInfo() + return { valid: false, expired: true } + } + + // 网络错误等情况,保持本地状态 + return { valid: false, error } + } +} + +/** + * 微信手机号快速登录 + * @param {string} code - 微信getPhoneNumber返回的code + * @returns {Promise<{success: boolean, user?: object, error?: string}>} + */ +async function wxPhoneLogin(code, loginCode) { + try { + const res = await api.auth.wxPhoneLogin(code, loginCode) + + if (res.success && res.data) { + const { token, user } = res.data + + // 计算过期时间(7天后,与后端保持一致) + const expiresAt = new Date() + expiresAt.setDate(expiresAt.getDate() + 7) + + // 保存登录信息 + saveUserInfo(user, token, expiresAt.toISOString()) + + return { success: true, user } + } + + return { success: false, error: res.error || '登录失败' } + } catch (error) { + console.error('微信手机号登录失败:', error) + return { success: false, error: error.message || '网络错误' } + } +} + +/** + * 保存用户信息到本地 + * @param {object} user - 用户信息 + * @param {string} token - 登录Token + * @param {string} expiresAt - 过期时间(可选) + */ +function saveUserInfo(user, token, expiresAt = null) { + if (token) { + wx.setStorageSync(TOKEN_KEY, token) + } + + if (user) { + wx.setStorageSync(USER_KEY, user) + if (user.id) { + wx.setStorageSync(USER_ID_KEY, user.id) + } + } + + if (expiresAt) { + wx.setStorageSync(TOKEN_EXPIRY_KEY, expiresAt) + } + + // 登录成功后,检查并绑定推荐码 + if (user && user.id && token) { + checkAndBindReferralCode(user.id, token) + } +} + +/** + * 检查并绑定推荐码(佣金系统) + * 在用户登录成功后自动调用 + * 包含重试机制,确保网络波动时也能成功绑定 + * @param {string} userId - 用户ID + * @param {string} token - 登录token + * @param {number} retryCount - 重试次数(内部使用) + */ +async function checkAndBindReferralCode(userId, token, retryCount = 0) { + const MAX_RETRY = 3 + const REFERRAL_CODE_KEY = 'pendingReferralCode' + + try { + const referralCode = wx.getStorageSync(REFERRAL_CODE_KEY) + + if (!referralCode) { + console.log('[推荐码绑定] 没有待绑定的推荐码') + return + } + + console.log(`[推荐码绑定] 检测到待绑定推荐码: ${referralCode}, 重试次数: ${retryCount}`) + + const res = await api.commission.bindReferral(referralCode) + + if (res.success) { + wx.removeStorageSync(REFERRAL_CODE_KEY) + const app = getApp() + if (app && app.globalData) { + app.globalData.pendingReferralCode = null + } + console.log('[推荐码绑定] 推荐关系绑定成功') + } else { + console.log('[推荐码绑定] 推荐关系绑定失败:', res.error) + + if (res.error && ( + res.error.includes('已绑定') || + res.error.includes('自己') || + res.error.includes('无效') + )) { + wx.removeStorageSync(REFERRAL_CODE_KEY) + const app = getApp() + if (app && app.globalData) { + app.globalData.pendingReferralCode = null + } + console.log('[推荐码绑定] 已清除无效的推荐码') + } else if (retryCount < MAX_RETRY) { + console.log(`[推荐码绑定] 将在5秒后进行第${retryCount + 1}次重试...`) + await new Promise(resolve => setTimeout(resolve, 5000)) + await checkAndBindReferralCode(userId, token, retryCount + 1) + } else { + console.log('[推荐码绑定] 重试次数已用尽,绑定失败') + } + } + } catch (error) { + console.error('[推荐码绑定] 绑定推荐码失败:', error) + + if (retryCount < MAX_RETRY) { + console.log(`[推荐码绑定] 网络错误,5秒后进行第${retryCount + 1}次重试...`) + await new Promise(resolve => setTimeout(resolve, 5000)) + await checkAndBindReferralCode(userId, token, retryCount + 1) + } else { + console.log('[推荐码绑定] 重试次数已用尽,网络错误') + } + } +} + +/** + * 清除登录信息 + */ +function clearLoginInfo() { + wx.removeStorageSync(TOKEN_KEY) + wx.removeStorageSync(USER_KEY) + wx.removeStorageSync(USER_ID_KEY) + wx.removeStorageSync(TOKEN_EXPIRY_KEY) + wx.removeStorageSync(config.STORAGE_KEYS.REFRESH_TOKEN) +} + +/** + * 退出登录 + * @returns {Promise} + */ +async function logout() { + const token = getToken() + + if (token) { + try { + await api.auth.logout() + } catch (error) { + console.error('退出登录API调用失败:', error) + } + } + + clearLoginInfo() +} + +/** + * 检查并恢复登录状态 + * 用于小程序启动时调用 + * @returns {Promise<{isLoggedIn: boolean, user?: object}>} + */ +async function checkAndRestoreLogin() { + // 1. 先检查本地状态 + if (!isLoggedIn()) { + return { isLoggedIn: false } + } + + // 2. 验证服务端状态 + const result = await verifyLogin() + + if (result.valid) { + return { isLoggedIn: true, user: result.user } + } + + // 3. 验证失败,清除本地状态 + if (result.expired) { + console.log('Token已过期,需要重新登录') + } + + return { isLoggedIn: false } +} + +/** + * 需要登录时的处理 + * 跳转到登录页面 + * @param {string} redirectUrl - 登录成功后跳转的URL(可选) + */ +function requireLogin(redirectUrl = '') { + const url = redirectUrl + ? `/pages/login/login?redirect=${encodeURIComponent(redirectUrl)}` + : '/pages/login/login' + + wx.navigateTo({ + url, + fail: () => { + // 如果navigateTo失败,尝试redirectTo + wx.redirectTo({ url }) + } + }) +} + +/** + * 显示登录提示弹窗 + * @param {string} redirectUrl - 登录成功后跳转的URL(可选) + * @returns {Promise} 用户是否选择去登录 + */ +function showLoginTip(redirectUrl = '') { + return new Promise((resolve) => { + wx.showModal({ + title: '提示', + content: '请先登录后再操作', + confirmText: '去登录', + confirmColor: '#b06ab3', + success: (res) => { + if (res.confirm) { + requireLogin(redirectUrl) + resolve(true) + } else { + resolve(false) + } + } + }) + }) +} + +/** + * 页面级登录验证(统一验证机制) + * 用于页面onLoad时调用,确保用户已登录且token有效 + * + * @param {object} options - 配置选项 + * @param {string} options.pageName - 页面名称(用于日志) + * @param {string} options.redirectUrl - 未登录时的跳转URL + * @param {boolean} options.silent - 是否静默失败(不显示提示) + * @returns {Promise} 是否已登录且token有效 + * + * @example + * // 在页面onLoad中使用 + * async onLoad() { + * const isValid = await auth.ensureLogin({ + * pageName: 'gift-shop', + * redirectUrl: '/pages/gift-shop/gift-shop' + * }) + * + * if (!isValid) return + * + * // 继续加载页面数据 + * this.loadData() + * } + */ +async function ensureLogin(options = {}) { + const { + pageName = 'unknown', + redirectUrl = '', + silent = false + } = options + + const config = require('../config/index') + const app = getApp() + + console.log(`[auth.ensureLogin] ${pageName} - 开始验证登录状态`) + + // 1. 等待app初始化完成 + if (app && app.waitForLoginCheck) { + await app.waitForLoginCheck() + console.log(`[auth.ensureLogin] ${pageName} - app登录检查完成`) + } + + // 2. 检查全局登录状态 + if (!app || !app.globalData || !app.globalData.isLoggedIn) { + console.warn(`[auth.ensureLogin] ${pageName} - 全局状态显示未登录`) + if (!silent) { + wx.redirectTo({ + url: `/pages/login/login?redirect=${encodeURIComponent(redirectUrl)}` + }) + } + return false + } + + // 3. 检查token是否存在(最多重试3次) + let token = null + let retryCount = 0 + const maxRetries = 3 + + while (!token && retryCount < maxRetries) { + token = wx.getStorageSync(config.STORAGE_KEYS.TOKEN) + + if (!token) { + retryCount++ + console.warn(`[auth.ensureLogin] ${pageName} - Token不存在,第${retryCount}次重试`) + + if (retryCount < maxRetries) { + // 等待递增的时间:100ms, 200ms, 300ms + await new Promise(resolve => setTimeout(resolve, retryCount * 100)) + } + } + } + + if (!token) { + console.error(`[auth.ensureLogin] ${pageName} - Token在${maxRetries}次重试后仍然不存在`) + if (!silent) { + wx.redirectTo({ + url: `/pages/login/login?redirect=${encodeURIComponent(redirectUrl)}` + }) + } + return false + } + + // 4. 检查token是否过期 + const expiry = wx.getStorageSync(config.STORAGE_KEYS.TOKEN_EXPIRY) + if (expiry && new Date(expiry) <= new Date()) { + console.warn(`[auth.ensureLogin] ${pageName} - Token已过期`) + clearLoginInfo() + if (app && app.globalData) { + app.globalData.isLoggedIn = false + } + if (!silent) { + wx.redirectTo({ + url: `/pages/login/login?redirect=${encodeURIComponent(redirectUrl)}` + }) + } + return false + } + + // 5. 最终验证:再次确认token可以被获取(防止storage异步问题) + await new Promise(resolve => setTimeout(resolve, 50)) + const finalToken = wx.getStorageSync(config.STORAGE_KEYS.TOKEN) + if (!finalToken) { + console.error(`[auth.ensureLogin] ${pageName} - 最终验证失败,token无法获取`) + if (!silent) { + wx.redirectTo({ + url: `/pages/login/login?redirect=${encodeURIComponent(redirectUrl)}` + }) + } + return false + } + + console.log(`[auth.ensureLogin] ${pageName} - 验证通过,token有效 (长度: ${finalToken.length})`) + return true +} + +module.exports = { + // 状态检查 + isLoggedIn, + getLocalUserInfo, + getToken, + getUserId, + + // 登录验证 + verifyLogin, + checkAndRestoreLogin, + ensureLogin, // 新增:统一的页面级登录验证 + + // 登录操作 + wxPhoneLogin, + logout, + + // 数据管理 + saveUserInfo, + clearLoginInfo, + + // 推荐码绑定 + checkAndBindReferralCode, + + // 辅助方法 + requireLogin, + showLoginTip +} diff --git a/utils/errorHandler.js b/utils/errorHandler.js new file mode 100644 index 0000000..b38552b --- /dev/null +++ b/utils/errorHandler.js @@ -0,0 +1,237 @@ +/** + * 统一错误处理工具 + * 提供统一的错误提示和处理机制 + */ + +/** + * 错误类型映射 + */ +const ERROR_TYPES = { + NETWORK: 'network', + AUTH: 'auth', + BUSINESS: 'business', + UNKNOWN: 'unknown' +} + +/** + * 错误消息映射 + */ +const ERROR_MESSAGES = { + // 网络错误 + 'request:fail': '网络连接失败,请检查网络设置', + 'request:fail timeout': '请求超时,请稍后重试', + 'request:fail abort': '请求已取消', + + // 认证错误 + 401: '登录已过期,请重新登录', + 403: '没有权限访问', + + // 业务错误 + 400: '请求参数错误', + 404: '请求的资源不存在', + 500: '服务器错误,请稍后重试', + + // 默认错误 + default: '操作失败,请稍后重试' +} + +/** + * 判断错误类型 + * @param {object} error - 错误对象 + * @returns {string} 错误类型 + */ +function getErrorType(error) { + if (!error) return ERROR_TYPES.UNKNOWN + + // 网络错误 + if (error.errMsg && error.errMsg.includes('request:fail')) { + return ERROR_TYPES.NETWORK + } + + // 认证错误 + if (error.code === 401 || error.statusCode === 401) { + return ERROR_TYPES.AUTH + } + + // 业务错误 + if (error.code || error.statusCode) { + return ERROR_TYPES.BUSINESS + } + + return ERROR_TYPES.UNKNOWN +} + +/** + * 获取错误消息 + * @param {object} error - 错误对象 + * @returns {string} 错误消息 + */ +function getErrorMessage(error) { + if (!error) return ERROR_MESSAGES.default + + // 优先使用后端返回的错误消息 + if (error.message) return error.message + if (error.error) return error.error + if (error.msg) return error.msg + + // 根据错误码获取消息 + const code = error.code || error.statusCode + if (code && ERROR_MESSAGES[code]) { + return ERROR_MESSAGES[code] + } + + // 根据错误信息获取消息 + if (error.errMsg && ERROR_MESSAGES[error.errMsg]) { + return ERROR_MESSAGES[error.errMsg] + } + + return ERROR_MESSAGES.default +} + +/** + * 显示错误提示 + * @param {object} error - 错误对象 + * @param {object} options - 选项 + */ +function showError(error, options = {}) { + const { + silent = false, // 是否静默(不显示提示) + duration = 2000, // 提示持续时间 + icon = 'none', // 图标类型 + mask = false // 是否显示透明蒙层 + } = options + + if (silent) return + + const message = getErrorMessage(error) + const type = getErrorType(error) + + // 认证错误不显示toast,由auth模块处理 + if (type === ERROR_TYPES.AUTH) { + return + } + + wx.showToast({ + title: message, + icon, + duration, + mask + }) +} + +/** + * 处理API错误 + * @param {object} error - 错误对象 + * @param {object} options - 选项 + * @returns {object} 处理后的错误对象 + */ +function handleApiError(error, options = {}) { + const { + showToast = true, // 是否显示错误提示 + logError = true, // 是否记录错误日志 + customMessage = null // 自定义错误消息 + } = options + + // 记录错误日志 + if (logError) { + console.error('[Error Handler]', error) + } + + // 显示错误提示 + if (showToast) { + showError(error, { + silent: false, + duration: 2000 + }) + } + + // 返回标准化的错误对象 + return { + type: getErrorType(error), + message: customMessage || getErrorMessage(error), + code: error.code || error.statusCode || -1, + originalError: error + } +} + +/** + * 错误重试机制 + * @param {function} fn - 要重试的函数 + * @param {object} options - 选项 + * @returns {Promise} + */ +async function retryOnError(fn, options = {}) { + const { + maxRetries = 3, // 最大重试次数 + retryDelay = 1000, // 重试延迟(毫秒) + onRetry = null // 重试回调 + } = options + + let lastError = null + + for (let i = 0; i < maxRetries; i++) { + try { + return await fn() + } catch (error) { + lastError = error + + // 认证错误不重试 + if (getErrorType(error) === ERROR_TYPES.AUTH) { + throw error + } + + // 最后一次重试失败,抛出错误 + if (i === maxRetries - 1) { + throw error + } + + // 执行重试回调 + if (onRetry) { + onRetry(i + 1, maxRetries) + } + + // 等待后重试 + await new Promise(resolve => setTimeout(resolve, retryDelay)) + } + } + + throw lastError +} + +/** + * 检查网络状态 + * @returns {Promise} + */ +function checkNetworkStatus() { + return new Promise((resolve) => { + wx.getNetworkType({ + success: (res) => { + const networkType = res.networkType + if (networkType === 'none') { + wx.showToast({ + title: '网络未连接', + icon: 'none', + duration: 2000 + }) + resolve(false) + } else { + resolve(true) + } + }, + fail: () => { + resolve(true) // 获取失败时假设网络正常 + } + }) + }) +} + +module.exports = { + ERROR_TYPES, + ERROR_MESSAGES, + getErrorType, + getErrorMessage, + showError, + handleApiError, + retryOnError, + checkNetworkStatus +} diff --git a/utils/imageUrl.js b/utils/imageUrl.js new file mode 100644 index 0000000..1d9c724 --- /dev/null +++ b/utils/imageUrl.js @@ -0,0 +1,121 @@ +/** + * 图片URL处理工具 + * 统一处理相对路径和绝对路径的图片URL + */ + +const config = require('../config/index') + +function getImageBaseUrl() { + const apiBaseUrl = String(config?.API_BASE_URL || '').trim() + if (!apiBaseUrl) return 'https://ai-c.maimanji.com' + try { + const parsed = new URL(apiBaseUrl) + return parsed.origin + } catch (_) { + return apiBaseUrl.replace(/\/api\/?$/, '').replace(/\/+$/, '') + } +} + +// 获取图片基础URL(只保留 origin) +const IMAGE_BASE_URL = getImageBaseUrl() + +/** + * 转换图片URL为完整地址 + * @param {string} url - 图片URL(可能是相对路径或绝对路径) + * @param {string} defaultImage - 默认图片路径(可选) + * @returns {string} 完整的图片URL + */ +function getFullImageUrl(url, defaultImage = '') { + // 如果没有URL,返回默认图片 + if (!url) { + return defaultImage || '/images/icon-empty.png' + } + + if (url.startsWith('wxfile://')) { + return defaultImage || '/images/icon-empty.png' + } + + if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) { + try { + const parsed = new URL(url) + if (parsed.pathname.startsWith('/uploads/')) { + parsed.pathname = `/api/uploads/${parsed.pathname.slice('/uploads/'.length)}` + return parsed.toString() + } + if (/^\/(avatars|characters|audio|documents|assets|interest-partners|exchange|products|temp)\//.test(parsed.pathname)) { + parsed.pathname = `/api/uploads${parsed.pathname}` + return parsed.toString() + } + return url + } catch (_) { + return url + } + } + + // 如果是本地图片路径(/images/开头),直接返回 + if (url.startsWith('/images/')) { + return url + } + + let processedUrl = url + if (processedUrl.startsWith('/uploads/')) { + processedUrl = `/api/uploads/${processedUrl.slice('/uploads/'.length)}` + } else if (processedUrl.startsWith('uploads/')) { + processedUrl = `/api/uploads/${processedUrl.slice('uploads/'.length)}` + } else if (/^(\/)?(avatars|characters|audio|documents|assets|interest-partners|exchange|products|temp)\//.test(processedUrl)) { + processedUrl = processedUrl.startsWith('/') ? `/api/uploads${processedUrl}` : `/api/uploads/${processedUrl}` + } + + // 其他相对路径,拼接服务器地址 + // 确保URL以/开头 + const normalizedUrl = processedUrl.startsWith('/') ? processedUrl : '/' + processedUrl + return IMAGE_BASE_URL + normalizedUrl +} + +/** + * 批量转换图片URL数组 + * @param {Array} urls - 图片URL数组 + * @returns {Array} 完整的图片URL数组 + */ +function getFullImageUrls(urls) { + if (!Array.isArray(urls)) { + return [] + } + return urls.map(url => getFullImageUrl(url)) +} + +/** + * 处理用户头像URL + * @param {string} avatar - 头像URL + * @returns {string} 完整的头像URL + */ +function getAvatarUrl(avatar) { + return getFullImageUrl(avatar, '/images/default-avatar.svg') +} + +/** + * 处理角色头像URL + * @param {string} avatar - 角色头像URL + * @returns {string} 完整的头像URL + */ +function getCharacterAvatarUrl(avatar) { + return getFullImageUrl(avatar, '/images/character-default.png') +} + +/** + * 处理活动封面URL + * @param {string} cover - 封面URL + * @returns {string} 完整的封面URL + */ +function getActivityCoverUrl(cover) { + return getFullImageUrl(cover, '/images/activity-default.jpg') +} + +module.exports = { + getFullImageUrl, + getFullImageUrls, + getAvatarUrl, + getCharacterAvatarUrl, + getActivityCoverUrl, + IMAGE_BASE_URL +} diff --git a/utils/payment.js b/utils/payment.js new file mode 100644 index 0000000..dd2822d --- /dev/null +++ b/utils/payment.js @@ -0,0 +1,321 @@ +/** + * 微信支付工具类 + * 封装微信支付相关功能 + */ + +const api = require('./api') +const config = require('../config/index') + +/** + * 发起微信支付 + * @param {string} orderId - 订单ID + * @param {string} orderType - 订单类型: recharge/vip/companion/gift + * @returns {Promise} + */ +const requestPayment = async (orderId, orderType = 'recharge') => { + try { + console.log('[Payment] ========== 开始支付流程 ==========') + console.log('[Payment] 订单ID:', orderId) + console.log('[Payment] 订单类型:', orderType) + + // 1. 调用后端API获取支付参数 + wx.showLoading({ title: '正在调起支付...', mask: true }) + + const paymentParams = await api.payment.prepay({ + orderId, + orderType + }) + + console.log('[Payment] 获取支付参数成功') + console.log('[Payment] 完整响应:', JSON.stringify(paymentParams)) + + // 后端返回格式: { success: true, data: { timeStamp, nonceStr, package, signType, paySign, ... } } + if (!paymentParams.success || !paymentParams.data) { + throw new Error(paymentParams.error || paymentParams.message || '获取支付参数失败') + } + + // 只提取微信支付需要的5个参数,忽略其他字段(如 total_fee, orderId 等) + const { + timeStamp, + nonceStr, + package: packageValue, + signType, + paySign, + // 以下字段仅用于日志,不传递给 wx.requestPayment + total_fee, + orderId: responseOrderId, + orderNo + } = paymentParams.data + + console.log('[Payment] 支付参数解析:') + console.log(' - timeStamp:', timeStamp) + console.log(' - nonceStr:', nonceStr) + console.log(' - package:', packageValue) + console.log(' - signType:', signType) + console.log(' - paySign:', paySign ? paySign.substring(0, 20) + '...' : 'null') + console.log(' - total_fee (仅日志):', total_fee) + console.log(' - orderId (仅日志):', responseOrderId) + console.log(' - orderNo (仅日志):', orderNo) + + // 验证必要参数 + if (!timeStamp || !nonceStr || !packageValue || !paySign) { + console.error('[Payment] 支付参数不完整') + throw new Error('支付参数不完整') + } + + // 2. 调用微信支付API + wx.hideLoading() + + console.log('[Payment] 调用wx.requestPayment') + console.log('[Payment] ⚠️ 注意:只传递5个必需参数,不传递 total_fee') + + // 构造支付参数对象,确保只包含5个必需字段 + const paymentRequest = { + timeStamp: String(timeStamp), + nonceStr: String(nonceStr), + package: String(packageValue), + signType: signType || 'MD5', + paySign: String(paySign) + } + + console.log('[Payment] 最终支付参数:', JSON.stringify(paymentRequest)) + + return new Promise((resolve, reject) => { + wx.requestPayment({ + ...paymentRequest, + success: (res) => { + console.log('[Payment] ✓ 支付成功:', res) + resolve({ success: true, message: '支付成功' }) + }, + fail: (err) => { + console.error('[Payment] ✗ 支付失败:', err) + console.error('[Payment] 错误详情:', JSON.stringify(err)) + + // 用户取消支付 + if (err.errMsg === 'requestPayment:fail cancel') { + reject({ code: 'USER_CANCEL', message: '您已取消支付' }) + } + // 支付失败 + else { + reject({ + code: 'PAYMENT_FAIL', + message: err.errMsg || '支付失败,请稍后重试', + detail: err + }) + } + } + }) + }) + } catch (error) { + wx.hideLoading() + console.error('[Payment] ✗ 支付流程错误:', error) + throw error + } +} + +/** + * 查询订单支付状态 + * @param {string} orderId - 订单ID + * @param {number} confirm - 是否主动确认 (1/0) + * @returns {Promise} + */ +const queryOrderStatus = async (orderId, confirm = 0) => { + try { + console.log('[Payment] 查询订单状态:', orderId, 'confirm:', confirm) + const res = await api.payment.queryOrder(orderId, { confirm }) + console.log('[Payment] 订单状态:', res.data?.status) + return res + } catch (error) { + console.error('[Payment] 查询订单状态失败:', error) + throw error + } +} + +/** + * 轮询查询订单状态 + * @param {string} orderId - 订单ID + * @param {number} maxRetries - 最大重试次数 + * @param {number} interval - 轮询间隔(ms) + * @returns {Promise} + */ +const pollOrderStatus = (orderId, maxRetries = 30, interval = 2000) => { + return new Promise((resolve, reject) => { + let retries = 0 + + console.log('[Payment] 开始轮询订单状态') + console.log('[Payment] 最大重试次数:', maxRetries) + console.log('[Payment] 轮询间隔:', interval, 'ms') + + const poll = async () => { + try { + retries++ + console.log(`[Payment] 第 ${retries}/${maxRetries} 次查询`) + + // 前3次查询带 confirm=1 参数,促使后端主动向微信查询状态 + const confirm = retries <= 3 ? 1 : 0 + const res = await queryOrderStatus(orderId, confirm) + + if (res.success && res.data) { + const status = res.data.status + + console.log(`[Payment] 订单状态: ${status}`) + + // 支付成功 - 后端状态: completed + if (status === 'completed') { + console.log('[Payment] ✓ 订单支付成功并已完成') + resolve({ success: true, data: res.data, status: 'completed' }) + return + } + + // 已支付但未完成 - 后端状态: paid + if (status === 'paid') { + console.log('[Payment] ✓ 订单已支付,等待完成') + // 继续轮询,等待变为 completed + } + + // 支付失败 - 后端状态: cancelled + if (status === 'cancelled') { + console.log('[Payment] ✗ 订单已取消') + reject({ code: 'ORDER_CANCELLED', message: '订单已取消' }) + return + } + + // 继续轮询 + if (retries < maxRetries) { + console.log('[Payment] 订单状态为', status, ',继续轮询...') + setTimeout(poll, interval) + } else { + console.log('[Payment] ✗ 查询超时') + reject({ + code: 'TIMEOUT', + message: '支付结果查询超时,请稍后在订单列表中查看', + data: res.data + }) + } + } else { + reject({ code: 'QUERY_FAIL', message: res.error || res.message || '查询订单失败' }) + } + } catch (error) { + console.error('[Payment] 轮询查询失败:', error) + + // 如果是网络错误,继续重试 + if (retries < maxRetries) { + console.log('[Payment] 查询出错,继续重试...') + setTimeout(poll, interval) + } else { + reject(error) + } + } + } + + // 开始第一次查询 + poll() + }) +} + +/** + * 完整支付流程(创建订单 + 支付 + 查询状态) + * @param {object} orderData - 订单数据 + * @param {string} orderType - 订单类型 + * @returns {Promise} + */ +const completePayment = async (orderData, orderType) => { + try { + console.log('[Payment] ========== 完整支付流程开始 ==========') + console.log('[Payment] 测试模式:', config.TEST_MODE ? '开启' : '关闭') + + // 1. 创建订单 + wx.showLoading({ title: '创建订单中...', mask: true }) + + let orderRes + switch (orderType) { + case 'recharge': + orderRes = await api.payment.createRechargeOrder(orderData) + break + case 'vip': + orderRes = await api.payment.createVipOrder(orderData) + break + case 'companion': + orderRes = await api.companion.createOrder(orderData) + break + default: + throw new Error('不支持的订单类型') + } + + if (!orderRes.success || !orderRes.data) { + throw new Error(orderRes.message || '创建订单失败') + } + + const orderId = orderRes.data.orderId + console.log('[Payment] ✓ 订单创建成功:', orderId) + + wx.hideLoading() + + // 测试模式:跳过微信支付,直接模拟支付成功 + if (config.TEST_MODE) { + console.log('[Payment] ⚠️ 测试模式:跳过微信支付,直接模拟支付成功') + + wx.showLoading({ title: '模拟支付中...', mask: true }) + + // 延迟1秒模拟支付过程 + await new Promise(resolve => setTimeout(resolve, 1000)) + + // 调用后端测试支付接口,直接标记订单为已支付 + try { + const testPayRes = await api.payment.testPay({ orderId }) + console.log('[Payment] ✓ 测试支付成功:', testPayRes) + } catch (error) { + console.error('[Payment] ✗ 测试支付失败:', error) + // 即使测试支付接口失败,也继续流程(可能后端没有此接口) + } + + wx.hideLoading() + + // 显示成功提示 + wx.showToast({ + title: '支付成功(测试)', + icon: 'success', + duration: 2000 + }) + + console.log('[Payment] ========== 支付流程完成(测试模式)==========') + + return { + success: true, + orderId, + testMode: true, + message: '测试模式支付成功' + } + } + + // 正式模式:走真实微信支付流程 + // 2. 发起支付 + await requestPayment(orderId, orderType) + + // 3. 查询订单状态 + wx.showLoading({ title: '支付成功,处理中...', mask: true }) + + const statusRes = await pollOrderStatus(orderId) + + wx.hideLoading() + + console.log('[Payment] ========== 支付流程完成 ==========') + + return { + success: true, + orderId, + orderData: statusRes.data + } + } catch (error) { + wx.hideLoading() + console.error('[Payment] ========== 支付流程失败 ==========') + throw error + } +} + +module.exports = { + requestPayment, + queryOrderStatus, + pollOrderStatus, + completePayment +} diff --git a/utils/proactiveMessage.js b/utils/proactiveMessage.js new file mode 100644 index 0000000..3ecc281 --- /dev/null +++ b/utils/proactiveMessage.js @@ -0,0 +1,270 @@ +/** + * AI角色主动推送消息工具模块 + * + * 功能说明: + * 1. 跟进消息:发送给已聊过天但一段时间没互动的用户 + * 2. 打招呼消息:发送给还没有和任何角色聊过天的新用户 + */ + +const api = require('./api') +const config = require('../config/index') + +// 上次检查时间(避免频繁请求) +let lastCheckTime = 0 +// 检查间隔(毫秒)- 默认5分钟 +const CHECK_INTERVAL = 5 * 60 * 1000 + +/** + * 获取待接收的主动推送消息 + * @returns {Promise} 消息列表 + */ +async function getPendingMessages() { + try { + const token = wx.getStorageSync(config.STORAGE_KEYS.TOKEN) + if (!token) { + console.log('[proactiveMessage] 未登录,跳过获取推送消息') + return [] + } + + console.log('[proactiveMessage] 开始获取待推送消息...') + const res = await api.proactiveMessage.getPending() + console.log('[proactiveMessage] API响应:', JSON.stringify(res)) + + if (res.success && res.data) { + console.log('[proactiveMessage] 获取到消息数量:', res.data.length) + return res.data || [] + } + return [] + } catch (error) { + console.error('[proactiveMessage] 获取推送消息失败:', error) + return [] + } +} + +/** + * 检查并处理推送消息 + * 在首页 onShow 或定时调用 + * @param {object} options - 配置选项 + * @param {boolean} options.force - 是否强制检查(忽略时间间隔) + * @param {function} options.onNewMessages - 收到新消息时的回调 + * @returns {Promise} 消息列表 + */ +async function checkAndShowMessages(options = {}) { + const { force = false, onNewMessages } = options + + // 检查登录状态 + const app = getApp() + if (!app || !app.globalData || !app.globalData.isLoggedIn) { + console.log('[proactiveMessage] 未登录,跳过检查') + return [] + } + + // 检查时间间隔(避免频繁请求) + const now = Date.now() + if (!force && lastCheckTime && (now - lastCheckTime < CHECK_INTERVAL)) { + console.log('[proactiveMessage] 距离上次检查不足5分钟,跳过。上次:', lastCheckTime, '现在:', now) + return [] + } + + console.log('[proactiveMessage] 开始检查推送消息,force:', force) + lastCheckTime = now + + const messages = await getPendingMessages() + + if (messages.length > 0) { + console.log('[proactiveMessage] 收到推送消息:', messages.length, '条') + console.log('[proactiveMessage] 消息详情:', JSON.stringify(messages)) + + // 更新会话列表的未读状态 + messages.forEach(msg => { + updateConversationUnread(msg) + }) + + // 触发回调 + if (typeof onNewMessages === 'function') { + onNewMessages(messages) + } + } else { + console.log('[proactiveMessage] 没有待推送消息') + } + + return messages +} + +/** + * 更新会话列表未读状态 + * @param {object} msg - 推送消息对象 + */ +function updateConversationUnread(msg) { + // 获取当前会话列表缓存 + const cacheKey = 'proactive_messages_cache' + let cache = wx.getStorageSync(cacheKey) || {} + + // 记录消息,避免重复处理 + const msgKey = `${msg.character_id}_${msg.sent_at}` + if (cache[msgKey]) { + return // 已处理过 + } + + cache[msgKey] = { + character_id: msg.character_id, + character_name: msg.character_name, + content: msg.content, + message_type: msg.message_type, + sent_at: msg.sent_at, + processed_at: new Date().toISOString() + } + + // 清理过期缓存(保留最近24小时的记录) + const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000 + Object.keys(cache).forEach(key => { + const item = cache[key] + if (new Date(item.processed_at).getTime() < oneDayAgo) { + delete cache[key] + } + }) + + wx.setStorageSync(cacheKey, cache) + + // 触发页面更新 + triggerPageRefresh() +} + +/** + * 触发页面刷新 + * 通知当前页面刷新会话列表 + */ +function triggerPageRefresh() { + const pages = getCurrentPages() + if (pages.length === 0) return + + const currentPage = pages[pages.length - 1] + + // 如果当前页面有刷新会话列表的方法,调用它 + if (currentPage && typeof currentPage.loadConversations === 'function') { + currentPage.loadConversations() + } + + // 如果当前页面有刷新未读数的方法,调用它 + if (currentPage && typeof currentPage.loadUnreadCount === 'function') { + currentPage.loadUnreadCount() + } +} + +/** + * 获取角色的未处理推送消息 + * @param {string} characterId - 角色ID + * @returns {object|null} 消息对象 + */ +function getCharacterPendingMessage(characterId) { + const cacheKey = 'proactive_messages_cache' + const cache = wx.getStorageSync(cacheKey) || {} + + // 查找该角色的最新消息 + let latestMsg = null + Object.values(cache).forEach(msg => { + if (msg.character_id === characterId) { + if (!latestMsg || new Date(msg.sent_at) > new Date(latestMsg.sent_at)) { + latestMsg = msg + } + } + }) + + return latestMsg +} + +/** + * 清除角色的推送消息缓存 + * 用户进入聊天后调用 + * @param {string} characterId - 角色ID + */ +function clearCharacterMessages(characterId) { + const cacheKey = 'proactive_messages_cache' + let cache = wx.getStorageSync(cacheKey) || {} + + // 删除该角色的所有消息 + Object.keys(cache).forEach(key => { + if (cache[key].character_id === characterId) { + delete cache[key] + } + }) + + wx.setStorageSync(cacheKey, cache) +} + +/** + * 标记角色的推送消息为已读 + * 调用后端API标记已读,同时清除本地缓存 + * @param {string} characterId - 角色ID + */ +async function markAsRead(characterId) { + if (!characterId) { + console.log('[proactiveMessage] 没有角色ID,跳过标记已读') + return + } + + // 先清除本地缓存 + clearCharacterMessages(characterId) + + // 调用后端API标记已读 + try { + const res = await api.proactiveMessage.markAsRead({ + character_id: characterId + }) + console.log('[proactiveMessage] 标记已读结果:', JSON.stringify(res)) + } catch (err) { + console.log('[proactiveMessage] 标记已读失败:', err) + } +} + +/** + * 按消息ID列表标记已读 + * @param {Array} messageIds - 消息ID列表 + */ +async function markAsReadByIds(messageIds) { + if (!messageIds || messageIds.length === 0) { + return + } + + try { + const res = await api.proactiveMessage.markAsRead({ + message_ids: messageIds + }) + console.log('[proactiveMessage] 按ID标记已读结果:', JSON.stringify(res)) + } catch (err) { + console.log('[proactiveMessage] 按ID标记已读失败:', err) + } +} + +/** + * 启动定时检查 + * @param {number} interval - 检查间隔(毫秒),默认5分钟 + * @returns {number} 定时器ID + */ +function startPeriodicCheck(interval = CHECK_INTERVAL) { + return setInterval(() => { + checkAndShowMessages() + }, interval) +} + +/** + * 停止定时检查 + * @param {number} timerId - 定时器ID + */ +function stopPeriodicCheck(timerId) { + if (timerId) { + clearInterval(timerId) + } +} + +module.exports = { + getPendingMessages, + checkAndShowMessages, + getCharacterPendingMessage, + clearCharacterMessages, + markAsRead, + markAsReadByIds, + startPeriodicCheck, + stopPeriodicCheck, + CHECK_INTERVAL +} diff --git a/utils/util.js b/utils/util.js new file mode 100644 index 0000000..b69c843 --- /dev/null +++ b/utils/util.js @@ -0,0 +1,317 @@ +/** + * 工具函数 + */ + +const config = require('../config/index') + +/** + * 格式化时间 + * @param {Date|string|number} date - 日期 + * @param {string} format - 格式 (默认 'YYYY-MM-DD HH:mm:ss') + */ +const formatTime = (date, format = 'YYYY-MM-DD HH:mm:ss') => { + if (!date) return '' + + const d = new Date(date) + if (isNaN(d.getTime())) return '' + + const year = d.getFullYear() + const month = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + const hour = String(d.getHours()).padStart(2, '0') + const minute = String(d.getMinutes()).padStart(2, '0') + const second = String(d.getSeconds()).padStart(2, '0') + + return format + .replace('YYYY', year) + .replace('MM', month) + .replace('DD', day) + .replace('HH', hour) + .replace('mm', minute) + .replace('ss', second) +} + +/** + * 格式化相对时间 + * @param {Date|string|number} date - 日期 + */ +const formatRelativeTime = (date) => { + if (!date) return '' + + const d = new Date(date) + if (isNaN(d.getTime())) return '' + + const now = new Date() + const diff = now.getTime() - d.getTime() + const seconds = Math.floor(diff / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + const days = Math.floor(hours / 24) + + if (seconds < 60) return '刚刚' + if (minutes < 60) return `${minutes}分钟前` + if (hours < 24) return `${hours}小时前` + if (days < 7) return `${days}天前` + if (days < 30) return `${Math.floor(days / 7)}周前` + + // 超过30天显示具体日期 + return formatTime(d, 'MM-DD HH:mm') +} + +/** + * 格式化数字(添加千分位) + * @param {number} num - 数字 + */ +const formatNumber = (num) => { + if (num === null || num === undefined) return '0' + return Number(num).toLocaleString() +} + +/** + * 格式化金额 + * @param {number} amount - 金额(分) + * @param {boolean} showSymbol - 是否显示符号 + */ +const formatMoney = (amount, showSymbol = true) => { + if (amount === null || amount === undefined) return showSymbol ? '¥0.00' : '0.00' + const yuan = (amount / 100).toFixed(2) + return showSymbol ? `¥${yuan}` : yuan +} + +/** + * 手机号脱敏 + * @param {string} phone - 手机号 + */ +const maskPhone = (phone) => { + if (!phone || phone.length < 11) return phone + return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') +} + +/** + * 检查是否登录 + */ +const isLoggedIn = () => { + const token = wx.getStorageSync(config.STORAGE_KEYS.TOKEN) + return !!token +} + +/** + * 获取存储的用户信息 + */ +const getUserInfo = () => { + return wx.getStorageSync(config.STORAGE_KEYS.USER_INFO) || null +} + +/** + * 保存用户信息 + * @param {object} userInfo - 用户信息 + */ +const saveUserInfo = (userInfo) => { + wx.setStorageSync(config.STORAGE_KEYS.USER_INFO, userInfo) + if (userInfo && userInfo.id) { + wx.setStorageSync(config.STORAGE_KEYS.USER_ID, userInfo.id) + } +} + +/** + * 保存登录Token + * @param {string} token - Token + */ +const saveToken = (token) => { + wx.setStorageSync(config.STORAGE_KEYS.TOKEN, token) +} + +/** + * 清除登录状态 + */ +const clearAuth = () => { + wx.removeStorageSync(config.STORAGE_KEYS.TOKEN) + wx.removeStorageSync(config.STORAGE_KEYS.REFRESH_TOKEN) + wx.removeStorageSync(config.STORAGE_KEYS.USER_INFO) + wx.removeStorageSync(config.STORAGE_KEYS.USER_ID) +} + +/** + * 显示加载中 + * @param {string} title - 提示文字 + */ +const showLoading = (title = '加载中...') => { + wx.showLoading({ title, mask: true }) +} + +/** + * 隐藏加载中 + */ +const hideLoading = () => { + wx.hideLoading() +} + +/** + * 显示成功提示 + * @param {string} title - 提示文字 + */ +const showSuccess = (title) => { + wx.showToast({ title, icon: 'success' }) +} + +/** + * 显示错误提示 + * @param {string} title - 提示文字 + */ +const showError = (title) => { + wx.showToast({ title, icon: 'none' }) +} + +/** + * 显示普通提示 + * @param {string} title - 提示文字 + */ +const showToast = (title) => { + wx.showToast({ title, icon: 'none' }) +} + +/** + * 显示确认弹窗 + * @param {object} options - 选项 + */ +const showConfirm = (options) => { + return new Promise((resolve) => { + wx.showModal({ + title: options.title || '提示', + content: options.content || '', + showCancel: options.showCancel !== false, + cancelText: options.cancelText || '取消', + confirmText: options.confirmText || '确定', + confirmColor: options.confirmColor || '#b06ab3', + success: (res) => { + resolve(res.confirm) + } + }) + }) +} + +/** + * 防抖函数 + * @param {Function} fn - 函数 + * @param {number} delay - 延迟时间 + */ +const debounce = (fn, delay = 300) => { + let timer = null + return function (...args) { + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + fn.apply(this, args) + }, delay) + } +} + +/** + * 节流函数 + * @param {Function} fn - 函数 + * @param {number} interval - 间隔时间 + */ +const throttle = (fn, interval = 300) => { + let lastTime = 0 + return function (...args) { + const now = Date.now() + if (now - lastTime >= interval) { + lastTime = now + fn.apply(this, args) + } + } +} + +/** + * 深拷贝 + * @param {any} obj - 对象 + */ +const deepClone = (obj) => { + if (obj === null || typeof obj !== 'object') return obj + if (obj instanceof Date) return new Date(obj) + if (obj instanceof Array) return obj.map(item => deepClone(item)) + + const cloned = {} + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + cloned[key] = deepClone(obj[key]) + } + } + return cloned +} + +/** + * 生成唯一ID + */ +const generateId = () => { + return Date.now().toString(36) + Math.random().toString(36).substr(2, 9) +} + +/** + * 获取完整的图片URL + * @param {string} url - 原始URL + * @param {string} defaultImage - 默认图片 + */ +const getFullImageUrl = (url, defaultImage = '') => { + if (!url) return defaultImage || '' + + if (url.startsWith('wxfile://')) return defaultImage || '' + + // 如果已经是完整 URL 且不是 localhost, 直接返回 + if (url.startsWith('http') && !url.includes('localhost')) return url; + + // 如果是 localhost, 将其替换为正式域名 + if (url.includes('localhost')) { + const path = url.split(':3000')[1] || url.split('localhost')[1] + return `https://ai-c.maimanji.com${path.startsWith('/') ? '' : '/'}${path}` + } + + // 如果是 /images/ 开头的路径, 也需要拼接域名 + // 以前这里直接返回了 url, 导致微信小程序尝试加载本地资源而失败 + let baseUrl = 'https://ai-c.maimanji.com' + if (config && config.API_BASE_URL) { + try { + const parsed = new URL(String(config.API_BASE_URL)) + baseUrl = parsed.origin + } catch (_) { + baseUrl = String(config.API_BASE_URL).replace(/\/api\/?$/, '').replace(/\/+$/, '') + } + } + + let processedUrl = url + if (processedUrl.startsWith('/uploads/')) { + processedUrl = `/api/uploads/${processedUrl.slice('/uploads/'.length)}` + } else if (processedUrl.startsWith('uploads/')) { + processedUrl = `/api/uploads/${processedUrl.slice('uploads/'.length)}` + } else if (/^(\/)?(avatars|characters|audio|documents|assets|interest-partners|exchange|products|temp)\//.test(processedUrl)) { + processedUrl = processedUrl.startsWith('/') ? `/api/uploads${processedUrl}` : `/api/uploads/${processedUrl}` + } + + // 确保以 / 开头 + const normalizedUrl = processedUrl.startsWith('/') ? processedUrl : '/' + processedUrl + + return baseUrl + normalizedUrl +} + +module.exports = { + formatTime, + formatRelativeTime, + formatNumber, + formatMoney, + maskPhone, + isLoggedIn, + getUserInfo, + saveUserInfo, + saveToken, + clearAuth, + showLoading, + hideLoading, + showSuccess, + showError, + showToast, + showConfirm, + debounce, + throttle, + deepClone, + generateId, + getFullImageUrl +} diff --git a/utils_new/auth.js b/utils_new/auth.js new file mode 100644 index 0000000..aff590c --- /dev/null +++ b/utils_new/auth.js @@ -0,0 +1,51 @@ +const { request } = require('./request'); + +const login = async (userInfo) => { + const codeRes = await new Promise((resolve, reject) => { + wx.login({ + success: resolve, + fail: reject + }); + }); + + const code = codeRes.code; + const res = await request({ + url: '/api/auth/wx-login', + method: 'POST', + data: { code, userInfo: userInfo || null } + }); + + const body = res.data || {}; + if (!body.success) { + throw new Error(body.error || '登录失败'); + } + + const token = body.data?.token || ''; + if (!token) { + throw new Error('登录失败:缺少 token'); + } + + wx.setStorageSync('auth_token', token); + const app = getApp(); + if (app?.globalData) app.globalData.token = token; + return body.data?.user || null; +}; + +const fetchMe = async () => { + try { + const res = await request({ url: '/api/auth/me', method: 'GET' }); + const body = res.data || {}; + if (!body.success) throw new Error(body.message || '获取用户信息失败'); + return body.data; + } catch (e) { + const msg = e?.userMessage || e?.message || '获取用户信息失败'; + const err = new Error(msg); + err.baseUrl = e?.baseUrl; + throw err; + } +}; + +module.exports = { + login, + fetchMe +}; diff --git a/utils_new/payment.js b/utils_new/payment.js new file mode 100644 index 0000000..b09f4a7 --- /dev/null +++ b/utils_new/payment.js @@ -0,0 +1,93 @@ +const { request } = require('./request'); + +const createVipOrder = async ({ planId, duration }) => { + const res = await request({ + url: '/api/payment/vip', + method: 'POST', + data: { planId, duration, paymentMethod: 'wechat' } + }); + const body = res.data || {}; + if (!body.success) throw new Error(body.error || '创建VIP订单失败'); + return body.data; +}; + +const createProductOrder = async ({ productId, referralCode }) => { + const res = await request({ + url: `/api/products/${productId}/purchase`, + method: 'POST', + data: { payment_method: 'wechat', ...(referralCode ? { referral_code: referralCode } : {}) } + }); + const body = res.data || {}; + if (!body.success) throw new Error(body.error || '创建订单失败'); + const data = body.data || {}; + return { + orderId: data.order_id, + orderNo: data.order_no, + amount: data.amount, + productName: data.product_name, + expireAt: data.expire_at + }; +}; + +const getPrepayParams = async ({ orderId, orderType }) => { + const res = await request({ + url: '/api/payment/prepay', + method: 'POST', + data: { orderId, orderType } + }); + const body = res.data || {}; + if (!body.success) throw new Error(body.error || '获取支付参数失败'); + return body.data; +}; + +const requestPayment = async (params) => { + return new Promise((resolve, reject) => { + const paymentArgs = { + timeStamp: String(params.timeStamp), + nonceStr: params.nonceStr, + package: params.package, + signType: params.signType, + paySign: params.paySign, + // 部分老旧兼容可能需要 total_fee,尝试带上 + ...(params.total_fee ? { total_fee: params.total_fee } : {}) + }; + + console.log('[Payment] Requesting wx.requestPayment with:', JSON.stringify(paymentArgs)); + + wx.requestPayment({ + ...paymentArgs, + success: (res) => { + console.log('[Payment] Success:', res); + resolve(res); + }, + fail: (err) => { + console.error('[Payment] Fail:', err); + // 转换错误信息 + const msg = err.errMsg || ''; + if (msg.includes('parameter error') || msg.includes('missing parameter')) { + console.error('[Payment] Parameter Error Details:', paymentArgs); + } + reject(err); + } + }); + }); +}; + +const payVip = async ({ planId, duration }) => { + const order = await createVipOrder({ planId, duration }); + const prepay = await getPrepayParams({ orderId: order.orderId, orderType: 'vip' }); + await requestPayment(prepay); + return order; +}; + +const payProduct = async ({ productId, orderType, referralCode }) => { + const order = await createProductOrder({ productId, referralCode }); + const prepay = await getPrepayParams({ orderId: order.orderId, orderType }); + await requestPayment(prepay); + return order; +}; + +module.exports = { + payVip, + payProduct +}; diff --git a/utils_new/request.js b/utils_new/request.js new file mode 100644 index 0000000..c3392a3 --- /dev/null +++ b/utils_new/request.js @@ -0,0 +1,57 @@ +const getAppInstance = () => getApp(); +const config = require('../config/index'); + +const getDefaultBaseUrl = () => { + const apiBaseUrl = String(config?.API_BASE_URL || '').replace(/\/+$/, ''); + if (!apiBaseUrl) return 'https://ai-c.maimanji.com'; + return apiBaseUrl.endsWith('/api') ? apiBaseUrl.slice(0, -4) : apiBaseUrl; +}; + +const getBaseUrl = () => { + const app = getAppInstance(); + const fromGlobal = app?.globalData?.baseUrl; + const fromStorage = wx.getStorageSync('baseUrl'); + if (config?.ENV === 'development') { + const storageUrl = String(fromStorage || '').trim(); + const allowStorage = + /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(storageUrl) || + /^https?:\/\/(0\.0\.0\.0)(:\d+)?$/i.test(storageUrl); + return (allowStorage ? storageUrl : '') || fromGlobal || getDefaultBaseUrl(); + } + return fromStorage || fromGlobal || getDefaultBaseUrl(); +}; + +const getToken = () => wx.getStorageSync('auth_token') || ''; + +const request = ({ url, method = 'GET', data, header = {} }) => { + return new Promise((resolve, reject) => { + const baseUrl = getBaseUrl(); + wx.request({ + url: `${baseUrl}${url}`, + method, + data, + header: { + 'Content-Type': 'application/json', + ...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}), + ...header + }, + success: (res) => { + resolve(res); + }, + fail: (err) => { + const errMsg = err?.errMsg || ''; + const isConnRefused = /ERR_CONNECTION_REFUSED/i.test(errMsg); + const isRequestFail = /^request:fail/i.test(errMsg); + const userMessage = isConnRefused || isRequestFail + ? `网络连接失败:无法连接到 ${baseUrl},请确认后端服务已启动且接口地址正确` + : '网络请求失败'; + reject({ ...err, baseUrl, userMessage }); + } + }); + }); +}; + +module.exports = { + request, + getBaseUrl +}; diff --git a/前端样式修复经验.md b/前端样式修复经验.md new file mode 100644 index 0000000..d506ea5 --- /dev/null +++ b/前端样式修复经验.md @@ -0,0 +1,113 @@ +# 前端样式修复经验(小程序为主) + +本文整理了在本项目里做“像素级对齐 Figma”时,高频踩坑与可复用修复策略,重点覆盖微信小程序(WXML/WXSS)场景。 + +## 1. 先判断:是样式问题还是“没生效” + +很多“样式不对”的根因不是写错了,而是没有加载到新代码: + +- **确认正在编辑的就是运行中的项目目录**:开发者工具「详情」里的项目路径必须指向当前仓库的 `miniprogram` 目录。 +- **清缓存再编译**:开发者工具经常缓存 WXSS,建议「清缓存(全部)」+ 重新编译。 +- **确认页面路径一致**:例如 `pages/cooperation/cooperation` 与 `pages/profile/cooperation/cooperation` 可能并存,确保你改的是当前路由实际打开的页面。 + +## 2. 小程序的“宽度为什么只居中一小块” + +在小程序里,`button` + `scroll-view` + `flex` 的组合经常出现“看起来只占中间一小块”的情况。 + +**典型症状** +- 卡片明明写了 `width: 100%`,但实际渲染仍像居中固定宽度。 +- 文本被挤到很窄,标题变成 `休...` 或出现逐字竖排。 + +**常见原因** +- 父容器未明确 `width: 100%`,或 `scroll-view` 的布局约束导致子项无法 stretch。 +- `button` 在某些布局里会表现为“内容宽度”,需要额外强制伸展。 +- 文本容器缺少 `min-width: 0`,导致 flex 收缩异常。 + +**可复用的修复模板** +- 父级容器明确宽度: + - `scroll-view`:`width: 100%` + - 外层容器:`width: 100%` +- 列表容器强制 stretch: + - `display: flex; flex-direction: column; align-items: stretch; width: 100%;` +- 按钮强制占满: + - `width: 100%; min-width: 100%; align-self: stretch; box-sizing: border-box;` +- 文本区域允许正确收缩: + - `min-width: 0;` + +## 3. 文本“逐字竖排/奇怪换行”的处理 + +**典型症状** +- 标题被拆成一列:`休 / 闲 / 娱 / 乐` +- 或者标题过早省略/换行,不符合设计稿 + +**处理策略** +- 让标题与副标题稳定单行: + - `display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;` +- 对于 flex 子项的文本容器,必须补: + - `min-width: 0;` + +## 4. scroll-view 下“底部被遮挡/露出不完整” + +**典型症状** +- 最底部按钮/卡片被 tabBar、底部安全区、或页面 padding 覆盖。 +- 看起来像是“卡片被裁掉一截”。 + +**根因** +- `.safe-bottom` 加在 page 上不等于加在 `scroll-view` 的可滚动内容上。 +- `scroll-view` 本身高度固定,但内容缺少足够的 `padding-bottom`。 + +**推荐修复** +- 直接给 `scroll-view` 增加安全区 padding: + +```css +.content { + padding-bottom: calc(constant(safe-area-inset-bottom) + 48rpx); + padding-bottom: calc(env(safe-area-inset-bottom) + 48rpx); +} +``` + +并给最后一个卡片额外 `margin-bottom`,确保视觉呼吸感与可点击区域安全。 + +## 5. gap 兼容性:能不用就不用 + +WXSS 对 `gap`(尤其是 `flex-gap`)在不同基础库/机型上可能出现不一致(或表现和浏览器不同)。 + +**建议** +- 列表纵向间距优先用: + - `.item + .item { margin-top: 32rpx; }` +- 行内间距用 margin(例如 `icon` 与文本之间 `margin-right`)。 + +## 6. button 默认样式与点击态 + +小程序 `button` 默认会带: +- 内边距/圆角/背景 +- `::after` 的边框 +- 不同平台的点击态 + +**建议** +- 统一使用 `.btn-reset`(项目已有全局定义)清除默认干扰: + - `padding:0; margin:0; background:transparent;` + - `.btn-reset::after { border:none; }` + +## 7. 像素级对齐的工作流(推荐) + +1. **先拿到 Figma 的“明确数值”**:宽高、圆角、阴影、字体、行高、padding/gap。 +2. **结构先对齐,再对齐视觉**:先搭 WXML 的盒子结构,保证布局稳定,再上阴影/渐变/图标。 +3. **把关键 UI 组件 token 化**:比如统一 `cardRadius/btnHeight/shadow`,避免每个页面都手写一套。 +4. **最小化依赖**:图标/插画尽量用导出的资源或现有 icon 组件,减少渲染差异。 +5. **用“对照图”验收**:左侧 Figma,右侧真机预览;优先修复影响布局的约束(宽度/对齐/滚动)。 + +## 8. 本项目中一次典型问题复盘(合作入驻页) + +**问题 1:入口卡片居中变窄** +- 原因:按钮在 scroll-view + flex 下未伸展 + 文本容器可用宽度被压缩。 +- 修复:父容器/列表容器/按钮三层都强制 `width:100%`,并给文本容器补 `min-width:0`。 + +**问题 2:底部“我的订单”卡片露出不完整** +- 原因:`scroll-view` 缺少安全区 padding-bottom。 +- 修复:给 `scroll-view.content` 增加 `padding-bottom: calc(env(safe-area-inset-bottom)+48rpx)`,并给最后卡片加 `margin-bottom`。 + +--- + +如果你希望把这套经验进一步沉淀成“组件级规范”(卡片/列表项/按钮/导航栏 token + 通用 mixin),我也可以基于当前项目结构继续抽象一套可复用的 UI 规范文档与组件模板。 + diff --git a/小程序前端开发与后端API对接经验汇总.md b/小程序前端开发与后端API对接经验汇总.md new file mode 100644 index 0000000..36000bd --- /dev/null +++ b/小程序前端开发与后端API对接经验汇总.md @@ -0,0 +1,219 @@ +# 小程序前端开发与后端 API 对接经验汇总 + +适用范围:本仓库小程序前端(`qianduan/qianduan-code/miniprogram`)。目标是让新同学能在不踩坑的情况下完成页面开发、样式对齐和后端 API 对接。 + +## 1. 项目结构与入口 + +- 小程序目录:`qianduan/qianduan-code/miniprogram` +- 全局入口:`app.js / app.json / app.wxss` +- 页面目录:`pages/*` +- 通用能力: + - 请求封装:`utils/api.js` + - 登录态:`utils/auth.js` + - 统一错误处理:`utils/errorHandler.js` + - 图片 URL 处理:`utils/imageUrl.js` +- 环境与常量:`config/index.js` + +## 2. 环境与 baseURL(非常关键) + +后端 API 的 baseURL 由 `config/index.js` 管理: + +- `ENV.{development,staging,production}.API_BASE_URL` +- `CURRENT_ENV` +- `REQUEST_TIMEOUT`(默认 30s) +- `PAGE_SIZE`(默认 20) +- `STORAGE_KEYS`(token/user 等 storage key 的唯一来源) + +建议实践: + +- 开发阶段用 `development`,发版/线上联调切 `production`。 +- 不要在页面里硬编码域名或 `/api` 前缀,一律走 `config.API_BASE_URL`。 + +另外,`app.js` 会把 `config.API_BASE_URL` 去掉 `/api` 后写到 `globalData.baseUrl` 并落地到 `storage.baseUrl`,主要给 `utils_new` 那套请求封装使用。正常业务开发推荐统一使用 `utils/api.js` 这一套,避免 token key 和 baseUrl 来源混乱。 + +## 3. 请求封装与 header 格式(统一约定) + +### 3.1 推荐做法:只用 `utils/api.js` + +`utils/api.js` 内部封装了 `request()`,完成以下事情: + +- URL 拼接:`config.API_BASE_URL + url` +- header 默认包含: + - `Content-Type: application/json` + - `Authorization: Bearer `(token 从 `wx.getStorageSync(config.STORAGE_KEYS.TOKEN)` 取) +- 401(未登录/登录过期)处理: + - 非 silent 模式会清理本地登录信息(token/user/userId/expiry) + - 同步 `app.globalData.isLoggedIn = false` + - 尝试调用当前页 `onAuthRequired()`(如果页面实现了该方法) +- `silent` 模式:用于不希望打断用户操作的接口;401 时不清本地登录态,只 reject + +页面层调用建议: + +```js +import api from '../../utils/api' +import { handleApiError } from '../../utils/errorHandler' + +Page({ + async onLoad() { + try { + wx.showLoading({ title: '加载中' }) + const res = await api.user.getProfile() + this.setData({ profile: res.data }) + } catch (err) { + handleApiError(err) + } finally { + wx.hideLoading() + } + } +}) +``` + +### 3.2 不推荐混用:`utils_new/request.js` + +仓库里还有一套 `utils_new/request.js`(更偏调试/可切 baseUrl),但它使用的 token key 是写死的 `auth_token`,而主体系使用 `config.STORAGE_KEYS.TOKEN`(同为 `auth_token` 但请以配置为准)。混用会导致以下问题: + +- 你以为登录了,实际请求没带对 token +- baseUrl 读取路径不同,导致部分页面请求走了另一个域名/端口 + +结论:业务开发优先只用 `utils/api.js`;除非明确是在做本地联调/临时调试,并保证 token/baseUrl 与主体系一致。 + +## 4. 登录态与鉴权(页面开发常见坑) + +核心逻辑在 `utils/auth.js` 和 `app.js`: + +- `app.js` 启动会调用 `checkLoginStatus()`: + - 先本地检查 token + - 再调用 `auth.verifyLogin()` 请求服务端 `/auth/me` 做校验 + - 网络异常时可能允许用本地缓存 userInfo 继续使用(提升弱网体验) +- 页面若必须登录才能访问,建议用 `auth.ensureLogin()` 做统一校验,校验失败会跳登录页 +- token/用户信息写入请走 `auth.saveUserInfo(user, token, expiresAt)` + +页面侧最佳实践: + +- 需要登录的页面,在 `onLoad/onShow` 里先 `await auth.ensureLogin()` 再拉数据 +- 有接口需要“静默拉取”(比如后台刷新余额),用 `api.request(url, { silent: true })` 或 API 方法暴露的 silent 选项(按现有实现为准) +- 需要 401 时做自定义交互的页面,提供 `onAuthRequired()` 方法(例如弹窗提示/引导登录),否则默认行为是清理登录态并由页面自行处理后续 + +## 5. 统一错误处理(减少页面重复代码) + +`utils/errorHandler.js` 提供了: + +- `handleApiError(err)`:统一 toast/提示文案 +- `retryOnError(fn, { maxRetries, retryDelay })`:可重试逻辑(认证错误不会重试) + +建议实践: + +- 页面 catch 里优先调用 `handleApiError(err)`,不要每个页面自己写一套 toast 文案 +- 对用户关键路径(下单/提现提交): + - 网络错误提示要明确 + - 禁止无限重试 + +## 6. 分页与列表加载(约定优先) + +当前工程的分页没有抽象成统一 `paginate()`,更多是“API 方法里给默认分页参数 + 页面侧传参”: + +- 常见参数:`page` + `pageSize`(或部分接口用 `limit`) +- 默认值可参考 `config.PAGE_SIZE` + +建议实践: + +- 页面 data 里维护:`page, pageSize, list, loading, hasMore` +- `onReachBottom` 时判 `hasMore && !loading` 再请求下一页 +- 后端返回是否还有更多,以返回字段为准(如果没有统一字段,就以 `list.length < pageSize` 推断) + +## 7. 上传与图片 URL(最容易漏 token) + +### 7.1 上传 + +使用 `utils/api.js` 的 `uploadFile()`: + +- 上传地址:`${config.API_BASE_URL}/upload` +- 携带 `Authorization: Bearer ` +- 支持 `formData.folder` 指定目录 +- 兼容多种返回格式(`{code:0}` 或 `{success:true}`) + +### 7.2 图片 URL 拼接 + +后端返回相对路径时,用 `utils/imageUrl.js` 的 `getFullImageUrl()` 拼成完整地址(基于 `API_BASE_URL` 去掉 `/api`)。 + +## 8. 头部与页面结构(统一样式框架) + +工程里常见的头部结构是“固定导航 + 状态栏占位 + 标题 + 返回按钮”: + +- 导航容器:`nav-container` +- 状态栏占位:`status-bar` +- 导航栏:`nav-bar` +- 返回按钮:`nav-back` +- 标题:`nav-title` + +常见页面结构示例可参考: + +- 提现页:`pages/withdraw/withdraw.wxml`、`pages/withdraw/withdraw.wxss` +- 充值页:`pages/recharge/recharge.wxml` +- 提现记录:`pages/withdraw-records/withdraw-records.wxml` + +建议实践: + +- 导航容器固定(fixed),内容区通过 `padding-top: {{totalNavHeight}}px` 或 `padding-top: {{totalNavHeight + n}}px` 避免被遮挡 +- 页面根节点常用:``,保证底部安全区 + +## 9. 整体样式风格(如何保持一致) + +### 9.1 全局设计令牌(Design Tokens) + +`app.wxss` 定义了全局 CSS 变量(建议优先使用): + +- `--primary`、`--primary-light` +- `--foreground`、`--muted`、`--border` +- `--radius` + +并补齐了全局基础类: + +- `.btn-reset`:用于 button,去掉默认边框与默认点击态差异 +- `.safe-bottom`:适配底部安全区 + +### 9.2 页面级常用视觉语言 + +在本项目里,“一致感”主要来自以下元素: + +- 背景:浅紫/粉色系渐变或纯色(如 `#E8C3D4`、`linear-gradient(180deg, #F8F5FF 0%, #FFFFFF 100%)`) +- 卡片:白底 + 大圆角(20rpx~48rpx)+ 轻阴影 + 适度边框 +- 主按钮:紫色渐变(`#B06AB3 → #9B4D9E`)+ 胶囊圆角(999rpx)+ 统一阴影 +- 文案层级:标题更粗更深(700~900),说明文字更浅(`#6B7280/#9CA3AF`) + +建议做法: + +- 新页面先找一个“风格相近”的现有页面抄结构与基础样式,再替换业务内容 +- 少做随意的颜色与圆角,优先复用现有渐变、阴影、字号层级 + +## 10. 交互与组件(避免嵌套交互坑) + +约定:交互元素不要互相嵌套(例如可点击容器里再放 button 或另一个可点击 view)。如果需要整块可点: + +- 要么外层用 `bindtap`,内部不用 button(用普通 view/text 模拟按钮) +- 要么使用 button 做唯一点击源,外层不再绑事件 + +这类嵌套会在检查工具/规范中触发警告,也容易造成点击穿透/事件冒泡问题。 + +## 11. 新增/修改 API 的推荐流程 + +1. 在 `utils/api.js` 增加/修改对应模块方法(保持命名与路径一致) +2. 请求一律通过内部 `request()`(确保 header、401、timeout、错误格式一致) +3. 页面侧只调用 `api.xxx.yyy()`,不直接拼 URL、不直接 `wx.request` +4. 异常统一走 `handleApiError`,少在页面写自定义错误文案 + +## 12. 排查清单(定位问题最快) + +- 请求没到后端: + - `config.CURRENT_ENV` 是否正确 + - `config.API_BASE_URL` 是否正确(是否带了 `/api`) + - 是否混用了 `utils_new` 导致 baseUrl/token 读取来源不一致 +- 401/未登录: + - storage 里是否存在 `config.STORAGE_KEYS.TOKEN` + - 页面是否需要 `auth.ensureLogin()` + - 是否误用了 silent 导致 401 没触发清理/引导 +- 样式不一致: + - 是否使用了 `.btn-reset`(button 默认样式会把你“设计稿一致性”破坏掉) + - 是否使用了 `.safe-bottom` + - 是否沿用了已有页面的卡片/按钮视觉语言 +