diff --git a/miniprogram/README.md b/miniprogram/README.md new file mode 100644 index 0000000..03d1070 --- /dev/null +++ b/miniprogram/README.md @@ -0,0 +1,82 @@ +# MBTI性格测试小程序 + +## 小程序信息 +- **AppID**: wx3d15ed02e98b04e3 +- **名称**: MBTI性格测试 +- **版本**: 1.0.0 + +## 功能模块 + +### 1. MBTI测试 +- 30道精简题目 +- 4个维度分析(E/I、S/N、T/F、J/P) +- 16种人格类型描述 +- 置信度计算 + +### 2. DISC测试 +- 20道题目 +- 4种行为风格(D/I/S/C) +- 职业建议和沟通技巧 + +### 3. PDP测试 +- 20道题目 +- 5种动物性格(老虎/孔雀/考拉/猫头鹰/变色龙) +- 团队角色分析 + +## 目录结构 + +``` +miniprogram/ +├── app.js # 小程序入口 +├── app.json # 全局配置 +├── app.wxss # 全局样式 +├── project.config.json # 项目配置 +├── sitemap.json # 站点地图 +├── pages/ +│ ├── index/ # 首页 +│ ├── test/ # 测试页面 +│ │ ├── mbti.* # MBTI测试 +│ │ ├── disc.* # DISC测试 +│ │ └── pdp.* # PDP测试 +│ ├── result/ # 结果页面 +│ │ ├── mbti.* +│ │ ├── disc.* +│ │ └── pdp.* +│ └── profile/ # 个人中心 +├── utils/ +│ ├── questions.js # 题库 +│ └── descriptions.js # 类型描述 +└── images/ # 图片资源 +``` + +## 使用方法 + +1. 下载并安装 [微信开发者工具](https://developers.weixin.qq.com/miniprogram/dev/devtools/download.html) + +2. 打开微信开发者工具,选择"导入项目" + +3. 选择 `miniprogram` 目录作为项目目录 + +4. 填入AppID: `wx3d15ed02e98b04e3` + +5. 点击"编译"即可预览 + +## 上传发布 + +1. 在微信开发者工具中点击"上传" + +2. 填写版本号和项目备注 + +3. 登录 [微信公众平台](https://mp.weixin.qq.com) 提交审核 + +## 注意事项 + +- tabBar图标需要替换为实际的PNG图片(81x81像素) +- 分享图片需要放置到 images/share.png +- 后端API地址需要在 app.js 中配置 + +## 技术栈 + +- 微信小程序原生开发 +- CSS3动画和过渡效果 +- 本地存储(wx.setStorageSync) diff --git a/miniprogram/app.js b/miniprogram/app.js new file mode 100644 index 0000000..27b3684 --- /dev/null +++ b/miniprogram/app.js @@ -0,0 +1,344 @@ +// app.js - MBTI小程序主入口 +const { request } = require('./utils/request.js') + +App({ + globalData: { + userInfo: null, + openId: null, + token: null, + siteTitle: '神仙团队AI性格测试', + textConfig: null, // 从 /api/config/runtime 动态加载:analyzingTitle, startButtonText, reportTitle, aiAnalysisText 等 + // 当前使用范围:personal 个人版 / enterprise 企业版(影响定价与 enterpriseId 写入) + appScope: 'personal', + // 扫码进入企业页时 scene 解析出的企业ID(e_123),提交测试/分析时优先使用 + enterpriseIdFromScene: null, + // API基础地址(开发时用本地,生产环境替换为实际域名) + apiBase: 'https://mbtiapi.quwanzhi.com', + //apiBase: 'http://mbti.com', + // VIP信息 + vipInfo: null, + // 测试次数 + testCount: 0, + // 已解锁的测试 + unlockedTests: [], + // 测试结果缓存 + mbtiResult: null, + discResult: null, + pdpResult: null, + aiResult: null + }, + + onLaunch() { + // 加载本地存储的数据 + this.loadStoredData() + + // 静默登录获取openId + this.silentLogin() + + // 预加载站点/小程序名称(供导航栏展示) + this.getRuntimeConfig().then((cfg) => { + if (cfg && cfg.siteTitle) { + this.globalData.siteTitle = cfg.siteTitle + } + }).catch(() => {}) + }, + + // 加载本地存储数据 + loadStoredData() { + const token = wx.getStorageSync('token') + if (token) { + this.globalData.token = token + } + const userInfo = wx.getStorageSync('userInfo') + if (userInfo) { + this.globalData.userInfo = userInfo + // 优先使用后端返回的 openid 字段,而不是内部自增 id + this.globalData.openId = userInfo.openid || userInfo.openId || null + } + + // VIP信息 + const vipInfo = wx.getStorageSync('vipInfo') + if (vipInfo) { + this.globalData.vipInfo = vipInfo + } + + // 测试次数 + const testCount = wx.getStorageSync('testCount') + if (testCount) { + this.globalData.testCount = testCount + } + + // 已解锁测试 + const unlockedTests = wx.getStorageSync('unlockedTests') + if (unlockedTests) { + this.globalData.unlockedTests = unlockedTests + } + + // 测试结果 + this.globalData.mbtiResult = wx.getStorageSync('mbtiResult') || null + this.globalData.discResult = wx.getStorageSync('discResult') || null + this.globalData.pdpResult = wx.getStorageSync('pdpResult') || null + this.globalData.aiResult = wx.getStorageSync('aiResult') || null + }, + + /** + * 静默登录:wx.login 取 code,请求后端换 token 与用户信息 + * @returns {Promise} 是否登录成功(拿到 token) + */ + silentLogin() { + return new Promise((resolve) => { + wx.login({ + success: (res) => { + if (!res.code) { + resolve(false) + return + } + const url = `${this.globalData.apiBase}/api/auth/wechat` + wx.request({ + url, + method: 'POST', + header: { 'Content-Type': 'application/json' }, + data: { code: res.code }, + success: (response) => { + if (response.statusCode === 200 && response.data && response.data.code === 200) { + const data = response.data.data || {} + const { token, user } = data + if (token) { + this.globalData.token = token + wx.setStorageSync('token', token) + } + if (user) { + this.globalData.userInfo = user + // 使用微信真实 openid,而不是 wechat_users 表的 id + this.globalData.openId = user.openid || user.openId || null + wx.setStorageSync('userInfo', user) + } + // 登录成功后处理分销绑定(若进入时携带了推荐人参数) + if (token) { + this._tryDistributionBind() + } + resolve(!!token) + return + } + resolve(false) + }, + fail: (err) => { + console.error('登录请求失败:', err) + const storedToken = wx.getStorageSync('token') + const storedUser = wx.getStorageSync('userInfo') + if (storedToken) { + this.globalData.token = storedToken + this.globalData.userInfo = storedUser || null + this.globalData.openId = storedUser ? storedUser.id : null + resolve(true) + } else { + resolve(false) + } + } + }) + }, + fail: () => resolve(false) + }) + }) + }, + + /** + * 确保已登录:有 token 直接 resolve,否则先执行静默登录 + * @returns {Promise} 当前是否有有效登录态 + */ + ensureLogin() { + if (this.globalData.token) return Promise.resolve(true) + return this.silentLogin() + }, + + /** + * 登录成功后尝试分销绑定(处理进入时携带的 uid 参数) + */ + _tryDistributionBind() { + const inviterId = this.globalData._pendingInviterId + const scope = this.globalData._pendingInviterScope || 'personal' + const eid = this.globalData._pendingInviterEid || null + + if (!inviterId || inviterId <= 0) return + + // 清除 pending 状态,避免重复绑定 + this.globalData._pendingInviterId = null + this.globalData._pendingInviterScope = null + this.globalData._pendingInviterEid = null + + const token = this.globalData.token + if (!token) return + + const data = { inviterId, scope } + if (eid) data.eid = eid + + request({ + url: '/api/distribution/bind', + method: 'POST', + data, + success: () => {}, + fail: () => {} + }) + }, + + /** 清除登录态(退出登录时调用) */ + logout() { + this.globalData.token = null + this.globalData.userInfo = null + this.globalData.openId = null + wx.removeStorageSync('token') + wx.removeStorageSync('userInfo') + }, + + // 获取用户信息(弹窗授权),并同步昵称/头像到后端 + getUserInfo(callback) { + wx.getUserProfile({ + desc: '用于展示用户头像和昵称', + success: (res) => { + const u = res.userInfo + this.globalData.userInfo = { ...this.globalData.userInfo, ...u, avatarUrl: u.avatarUrl || u.avatar } + wx.setStorageSync('userInfo', this.globalData.userInfo) + callback && callback(this.globalData.userInfo) + this.syncProfileToServer({ nickname: u.nickName, avatar: u.avatarUrl || u.avatar }) + }, + fail: () => { + callback && callback(this.globalData.userInfo || null) + } + }) + }, + + // 同步昵称、头像到后端(需已登录) + syncProfileToServer(profile, callback) { + if (!this.globalData.token || !profile) { + callback && callback(false) + return + } + + // 记录同步请求(调试用) + console.log('同步用户资料到服务器:', profile) + + request({ + url: '/api/auth/wechat/profile', + method: 'PUT', + data: profile, + success: (res) => { + console.log('同步用户资料响应:', res) + if (res.statusCode === 200 && res.data && res.data.code === 200) { + // 如果服务器返回了更新后的数据,使用服务器数据 + if (res.data.data) { + const updatedUserInfo = { ...this.globalData.userInfo, ...res.data.data } + // 确保 nickname 和 nickName 字段都更新 + if (profile.nickname) { + updatedUserInfo.nickname = profile.nickname + updatedUserInfo.nickName = profile.nickname + } + if (profile.avatar) { + updatedUserInfo.avatar = profile.avatar + updatedUserInfo.avatarUrl = profile.avatar + } + if (profile.birthday !== undefined) { + updatedUserInfo.birthday = profile.birthday + } + this.globalData.userInfo = updatedUserInfo + wx.setStorageSync('userInfo', updatedUserInfo) + console.log('用户资料已同步:', updatedUserInfo) + } else { + // 服务器没有返回数据,使用本地更新的数据 + const updatedUserInfo = { ...this.globalData.userInfo, ...profile } + if (profile.nickname) { + updatedUserInfo.nickname = profile.nickname + updatedUserInfo.nickName = profile.nickname + } + if (profile.avatar) { + updatedUserInfo.avatar = profile.avatar + updatedUserInfo.avatarUrl = profile.avatar + } + if (profile.birthday !== undefined) { + updatedUserInfo.birthday = profile.birthday + } + this.globalData.userInfo = updatedUserInfo + wx.setStorageSync('userInfo', updatedUserInfo) + console.log('用户资料已更新(本地):', updatedUserInfo) + } + callback && callback(true) + } else { + console.error('更新用户资料失败:', res.data) + callback && callback(false) + } + }, + fail: (err) => { + console.error('请求失败:', err) + callback && callback(false) + } + }) + }, + + // 保存测试结果 + saveTestResult(type, result) { + const key = `${type}Result` + wx.setStorageSync(key, result) + this.globalData[key] = result + + // 同步到服务器(需携带 token,后端从 JWT 解析 userId) + if (this.globalData.token) { + const scope = this.globalData.appScope || 'personal' + const storedUser = wx.getStorageSync('userInfo') || null + const enterpriseId = + scope === 'enterprise' + ? (this.globalData.enterpriseIdFromScene || (this.globalData.userInfo && this.globalData.userInfo.enterpriseId) || (storedUser && storedUser.enterpriseId) || null) + : null + wx.request({ + url: `${this.globalData.apiBase}/api/test/submit`, + method: 'POST', + header: { + 'Authorization': `Bearer ${this.globalData.token}`, + 'Content-Type': 'application/json' + }, + data: { + testType: type, + answers: result.answers || [], + result: result, + userId: this.globalData.userInfo?.id ?? this.globalData.openId, + enterpriseId: enterpriseId || undefined, + testDuration: result.testDuration || 0, + timestamp: new Date().toISOString() + } + }) + } + }, + + // 获取测试结果 + getTestResult(type) { + return this.globalData[`${type}Result`] || wx.getStorageSync(`${type}Result`) || null + }, + + /** + * 获取运行配置:个人/企业定价 + 当前 AI 服务商(超管配置,默认第一个启用的) + * 有 token 且用户属于企业则返回企业定价,否则个人定价 + * @returns {Promise<{pricingType, pricing, aiProviderId, aiProviderName}>} + */ + getRuntimeConfig() { + return new Promise((resolve, reject) => { + const scope = this.globalData.appScope || 'personal' + const base = this.globalData.apiBase.replace(/\/$/, '') + const url = `${base}/api/config/runtime?scope=${encodeURIComponent(scope)}` + const token = this.globalData.token || wx.getStorageSync('token') || '' + wx.request({ + url, + method: 'GET', + header: token ? { Authorization: 'Bearer ' + token } : {}, + success: (res) => { + if (res.statusCode === 200 && res.data && res.data.code === 200) { + const data = res.data.data || {} + if (data.siteTitle) this.globalData.siteTitle = data.siteTitle + if (data.textConfig) this.globalData.textConfig = data.textConfig + resolve(data) + } else { + reject(new Error(res.data && res.data.message ? res.data.message : '获取配置失败')) + } + }, + fail: reject + }) + }) + } +}) diff --git a/miniprogram/app.json b/miniprogram/app.json new file mode 100644 index 0000000..c6f3cd7 --- /dev/null +++ b/miniprogram/app.json @@ -0,0 +1,64 @@ +{ + "pages": [ + "pages/index/index", + "pages/index/camera", + "pages/index/upload", + "pages/index/result", + "pages/test-select/index", + "pages/test/mbti", + "pages/test/disc", + "pages/test/pdp", + "pages/result/mbti", + "pages/result/disc", + "pages/result/pdp", + "pages/result/resume", + "pages/purchase/index", + "pages/recharge/index", + "pages/enterprise/index", + "pages/enterprise/resume-history", + "pages/profile/index", + "pages/user-profile/index", + "pages/history/index", + "pages/phone-auth/index", + "pages/promo/index", + "pages/promo/poster", + "pages/promo/withdrawals" + ], + "tabBar": { + "custom": true, + "color": "#999999", + "selectedColor": "#7c3aed", + "backgroundColor": "#ffffff", + "borderStyle": "black", + "list": [ + { + "pagePath": "pages/index/index", + "text": "首页", + "iconPath": "images/home.png", + "selectedIconPath": "images/home-active.png" + }, + { + "pagePath": "pages/index/camera", + "text": "查看报告", + "iconPath": "images/camera.png", + "selectedIconPath": "images/camera-active.png" + }, + { + "pagePath": "pages/profile/index", + "text": "我的", + "iconPath": "images/user.png", + "selectedIconPath": "images/user-active.png" + } + ] + }, + "window": { + "backgroundTextStyle": "light", + "navigationBarBackgroundColor": "#FFFFFF", + "navigationBarTitleText": "神仙团队AI性格测试", + "navigationBarTextStyle": "black", + "backgroundColor": "#F5F5F5" + }, + "style": "v2", + "sitemapLocation": "sitemap.json", + "lazyCodeLoading": "requiredComponents" +} diff --git a/miniprogram/app.wxss b/miniprogram/app.wxss new file mode 100644 index 0000000..8e11059 --- /dev/null +++ b/miniprogram/app.wxss @@ -0,0 +1,176 @@ +/* app.wxss - 全局样式 */ + +/* 主题颜色 */ +page { + --primary-color: #FF6B8A; + --primary-light: #FFE4E9; + --secondary-color: #8B5CF6; + --secondary-light: #EDE9FE; + --success-color: #10B981; + --warning-color: #F59E0B; + --danger-color: #EF4444; + --text-primary: #1F2937; + --text-secondary: #6B7280; + --text-muted: #9CA3AF; + --bg-primary: #FFFFFF; + --bg-secondary: #F3F4F6; + --border-color: #E5E7EB; + + background-color: var(--bg-secondary); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 28rpx; + color: var(--text-primary); + line-height: 1.6; +} + +/* 玻璃卡片效果 */ +.glass-card { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(20px); + border-radius: 24rpx; + box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.08); + padding: 32rpx; + margin-bottom: 24rpx; +} + +/* 渐变背景 */ +.gradient-personal { + background: linear-gradient(135deg, #FF6B8A 0%, #FF8E53 100%); +} + +.gradient-enterprise { + background: linear-gradient(135deg, #8B5CF6 0%, #3B82F6 100%); +} + +/* 按钮样式 */ +.btn { + display: flex; + align-items: center; + justify-content: center; + height: 88rpx; + border-radius: 44rpx; + font-size: 32rpx; + font-weight: 600; + border: none; + transition: all 0.3s ease; +} + +.btn-primary { + background: linear-gradient(135deg, #FF6B8A 0%, #FF8E53 100%); + color: #FFFFFF; +} + +.btn-secondary { + background: #FFFFFF; + color: var(--primary-color); + border: 2rpx solid var(--primary-color); +} + +.btn-outline { + background: transparent; + color: var(--text-secondary); + border: 2rpx solid var(--border-color); +} + +.btn:active { + transform: scale(0.98); + opacity: 0.9; +} + +/* 进度条 */ +.progress-bar { + width: 100%; + height: 12rpx; + background: var(--bg-secondary); + border-radius: 6rpx; + overflow: hidden; +} + +.progress-bar-fill { + height: 100%; + border-radius: 6rpx; + transition: width 0.3s ease; +} + +/* 标签 */ +.tag { + display: inline-flex; + align-items: center; + padding: 8rpx 20rpx; + border-radius: 24rpx; + font-size: 24rpx; + font-weight: 500; +} + +.tag-primary { + background: var(--primary-light); + color: var(--primary-color); +} + +.tag-secondary { + background: var(--secondary-light); + color: var(--secondary-color); +} + +/* 文字样式 */ +.text-primary { + color: var(--text-primary); +} + +.text-secondary { + color: var(--text-secondary); +} + +.text-muted { + color: var(--text-muted); +} + +.text-center { + text-align: center; +} + +.font-bold { + font-weight: 700; +} + +.font-semibold { + font-weight: 600; +} + +/* 间距 */ +.mt-2 { margin-top: 16rpx; } +.mt-4 { margin-top: 32rpx; } +.mb-2 { margin-bottom: 16rpx; } +.mb-4 { margin-bottom: 32rpx; } +.p-4 { padding: 32rpx; } + +/* 弹性布局 */ +.flex { + display: flex; +} + +.flex-col { + flex-direction: column; +} + +.items-center { + align-items: center; +} + +.justify-center { + justify-content: center; +} + +.justify-between { + justify-content: space-between; +} + +.flex-1 { + flex: 1; +} + +/* 安全区域 */ +.safe-area-bottom { + padding-bottom: calc(constant(safe-area-inset-bottom) + 16rpx); + padding-bottom: calc(env(safe-area-inset-bottom) + 16rpx); +} diff --git a/miniprogram/custom-tab-bar/index.js b/miniprogram/custom-tab-bar/index.js new file mode 100644 index 0000000..dc50193 --- /dev/null +++ b/miniprogram/custom-tab-bar/index.js @@ -0,0 +1,74 @@ +// 自定义 tabBar(不使用微信内置):灰线 + 三栏,中间为浮起圆钮 +Component({ + data: { + selected: 0, + list: [ + { pagePath: '/pages/index/index', text: '首页', textKey: 'home', icon: 'home' }, + { pagePath: '/pages/index/camera', text: '查看报告', textKey: 'camera', icon: 'camera' }, + { pagePath: '/pages/profile/index', text: '我的', textKey: 'profile', icon: 'user' } + ] + }, + lifetimes: { + attached() { + this.updateSelected() + } + }, + pageLifetimes: { + show() { + this.updateSelected() + } + }, + methods: { + updateSelected() { + try { + const pages = getCurrentPages() + if (!pages || pages.length === 0) { + return + } + + const currentPage = pages[pages.length - 1] + if (!currentPage || !currentPage.route) { + return + } + + const url = currentPage.route + let selected = 0 + + if (url === 'pages/index/index' || url === 'pages/enterprise/index') { + selected = 0 + } else if (url === 'pages/index/camera') { + selected = 1 + } else if (url === 'pages/profile/index') { + selected = 2 + } + + this.setData({ selected }) + } catch (error) { + console.error('updateSelected error:', error) + // 默认选中第一个 + this.setData({ selected: 0 }) + } + }, + switchTab(e) { + const index = parseInt(e.currentTarget.dataset.index, 10) + let url = e.currentTarget.dataset.path + + // 点击"首页"(index=0)时,根据当前 scope 跳到企业版或个人版首页 + if (index === 0) { + try { + const app = getApp() + const scope = (app && app.globalData && app.globalData.appScope) || 'personal' + if (scope === 'enterprise') { + // 企业版:navigateTo(不是 tabBar 页面,不能用 switchTab) + wx.navigateTo({ url: '/pages/enterprise/index' }) + this.setData({ selected: index }) + return + } + } catch (e) {} + } + + wx.switchTab({ url }) + this.setData({ selected: index }) + } + } +}) diff --git a/miniprogram/custom-tab-bar/index.json b/miniprogram/custom-tab-bar/index.json new file mode 100644 index 0000000..467ce29 --- /dev/null +++ b/miniprogram/custom-tab-bar/index.json @@ -0,0 +1,3 @@ +{ + "component": true +} diff --git a/miniprogram/custom-tab-bar/index.wxml b/miniprogram/custom-tab-bar/index.wxml new file mode 100644 index 0000000..9bc06e8 --- /dev/null +++ b/miniprogram/custom-tab-bar/index.wxml @@ -0,0 +1,35 @@ + + + + + + + 首页 + + + + + + + + + + 我的 + + + diff --git a/miniprogram/custom-tab-bar/index.wxss b/miniprogram/custom-tab-bar/index.wxss new file mode 100644 index 0000000..f866393 --- /dev/null +++ b/miniprogram/custom-tab-bar/index.wxss @@ -0,0 +1,104 @@ +/* 自定义 tabBar:白底、顶部分割线、中间浮起圆钮 */ +.tab-bar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + background: #ffffff; + padding-bottom: env(safe-area-inset-bottom); + z-index: 9999; +} + +.tab-bar-line { + height: 1rpx; + background: #e5e7eb; + width: 100%; +} + +.tab-bar-inner { + display: flex; + align-items: flex-end; + justify-content: space-around; + height: 100rpx; + padding: 0 20rpx 8rpx; + position: relative; +} + +.tab-item { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + padding-bottom: 8rpx; +} + +.tab-icon { + width: 48rpx; + height: 48rpx; + margin-bottom: 4rpx; +} + +.tab-text { + font-size: 20rpx; + color: #999999; +} + +.tab-item.active .tab-text { + color: #7c3aed; +} + +/* 中间项:浮起彩色圆钮 + 相机图标 */ +.tab-item-center { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + margin-top: -56rpx; + padding-bottom: 8rpx; +} + +.center-circle { + width: 96rpx; + height: 96rpx; + min-width: 96rpx; + min-height: 96rpx; + border-radius: 50%; + background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 4rpx; + box-shadow: 0 4rpx 20rpx rgba(124, 58, 237, 0.4); + border: 4rpx solid #ffffff; + flex-shrink: 0; + overflow: hidden; +} + +.tab-item-center.active .center-circle { + background: linear-gradient(135deg, #6d28d9 0%, #7c3aed 100%); + box-shadow: 0 4rpx 20rpx rgba(124, 58, 237, 0.6); +} + +.center-icon { + width: 44rpx; + height: 44rpx; +} + +/* 占位:与「首页」「我的」文字等高,保证中间圆钮与两侧图标对齐 */ +.center-text-placeholder { + height: 24rpx; + width: 1rpx; + visibility: hidden; +} + +.tab-item-center .center-text { + color: #7c3aed; + font-size: 20rpx; + font-weight: 600; +} + +.tab-item-center.active .center-text { + color: #6d28d9; +} diff --git a/miniprogram/images/camera-active.png b/miniprogram/images/camera-active.png new file mode 100644 index 0000000..80c59a2 Binary files /dev/null and b/miniprogram/images/camera-active.png differ diff --git a/miniprogram/images/camera.png b/miniprogram/images/camera.png new file mode 100644 index 0000000..80c59a2 Binary files /dev/null and b/miniprogram/images/camera.png differ diff --git a/miniprogram/images/default-avatar.png b/miniprogram/images/default-avatar.png new file mode 100644 index 0000000..81bf4aa Binary files /dev/null and b/miniprogram/images/default-avatar.png differ diff --git a/miniprogram/images/home-active.png b/miniprogram/images/home-active.png new file mode 100644 index 0000000..4418853 Binary files /dev/null and b/miniprogram/images/home-active.png differ diff --git a/miniprogram/images/home.png b/miniprogram/images/home.png new file mode 100644 index 0000000..98b0cff Binary files /dev/null and b/miniprogram/images/home.png differ diff --git a/miniprogram/images/mbti-team-image.png b/miniprogram/images/mbti-team-image.png new file mode 100644 index 0000000..b95f0cf Binary files /dev/null and b/miniprogram/images/mbti-team-image.png differ diff --git a/miniprogram/images/user-active.png b/miniprogram/images/user-active.png new file mode 100644 index 0000000..cf1b6d0 Binary files /dev/null and b/miniprogram/images/user-active.png differ diff --git a/miniprogram/images/user.png b/miniprogram/images/user.png new file mode 100644 index 0000000..ef1fb59 Binary files /dev/null and b/miniprogram/images/user.png differ diff --git a/miniprogram/pages/ai-test/camera.js b/miniprogram/pages/ai-test/camera.js new file mode 100644 index 0000000..9555c82 --- /dev/null +++ b/miniprogram/pages/ai-test/camera.js @@ -0,0 +1 @@ +Page({}) \ No newline at end of file diff --git a/miniprogram/pages/ai-test/camera.json b/miniprogram/pages/ai-test/camera.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/miniprogram/pages/ai-test/camera.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/miniprogram/pages/ai-test/camera.wxml b/miniprogram/pages/ai-test/camera.wxml new file mode 100644 index 0000000..f966f72 --- /dev/null +++ b/miniprogram/pages/ai-test/camera.wxml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/miniprogram/pages/ai-test/camera.wxss b/miniprogram/pages/ai-test/camera.wxss new file mode 100644 index 0000000..0834804 --- /dev/null +++ b/miniprogram/pages/ai-test/camera.wxss @@ -0,0 +1 @@ +/* placeholder */ \ No newline at end of file diff --git a/miniprogram/pages/ai-test/index.js b/miniprogram/pages/ai-test/index.js new file mode 100644 index 0000000..9555c82 --- /dev/null +++ b/miniprogram/pages/ai-test/index.js @@ -0,0 +1 @@ +Page({}) \ No newline at end of file diff --git a/miniprogram/pages/ai-test/index.json b/miniprogram/pages/ai-test/index.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/miniprogram/pages/ai-test/index.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/miniprogram/pages/ai-test/index.wxml b/miniprogram/pages/ai-test/index.wxml new file mode 100644 index 0000000..f966f72 --- /dev/null +++ b/miniprogram/pages/ai-test/index.wxml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/miniprogram/pages/ai-test/index.wxss b/miniprogram/pages/ai-test/index.wxss new file mode 100644 index 0000000..0834804 --- /dev/null +++ b/miniprogram/pages/ai-test/index.wxss @@ -0,0 +1 @@ +/* placeholder */ \ No newline at end of file diff --git a/miniprogram/pages/ai-test/result.js b/miniprogram/pages/ai-test/result.js new file mode 100644 index 0000000..9555c82 --- /dev/null +++ b/miniprogram/pages/ai-test/result.js @@ -0,0 +1 @@ +Page({}) \ No newline at end of file diff --git a/miniprogram/pages/ai-test/result.json b/miniprogram/pages/ai-test/result.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/miniprogram/pages/ai-test/result.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/miniprogram/pages/ai-test/result.wxml b/miniprogram/pages/ai-test/result.wxml new file mode 100644 index 0000000..f966f72 --- /dev/null +++ b/miniprogram/pages/ai-test/result.wxml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/miniprogram/pages/ai-test/result.wxss b/miniprogram/pages/ai-test/result.wxss new file mode 100644 index 0000000..0834804 --- /dev/null +++ b/miniprogram/pages/ai-test/result.wxss @@ -0,0 +1 @@ +/* placeholder */ \ No newline at end of file diff --git a/miniprogram/pages/enterprise/index.js b/miniprogram/pages/enterprise/index.js new file mode 100644 index 0000000..724b0f2 --- /dev/null +++ b/miniprogram/pages/enterprise/index.js @@ -0,0 +1,214 @@ +// pages/enterprise/index.js - 企业版首页 +const app = getApp() +const { request } = require('../../utils/request') + +Page({ + data: { + statusBarHeight: 0, + navbarHeight: 88, + siteTitle: '神仙团队AI性格测试', + startButtonEnterprise: '开始面部测试', + aiAnalysisText: '智能分析' + }, + + onLoad(options) { + // 企业版首页:固定 scope=enterprise + const app = getApp() + try { app.globalData.appScope = 'enterprise' } catch (e) {} + + // ── 解析入参(兼容两种来源:扫码 scene / 分享链接 options)── + const rawScene = (options && options.scene) ? decodeURIComponent(options.scene) : '' + // 解析 scene 中的 key=value 对(如 uid=1&eid=6) + const sceneParams = {} + if (rawScene) { + rawScene.split('&').forEach(pair => { + const [k, v] = pair.split('=') + if (k) sceneParams[k] = v || '' + }) + } + + // 合并所有来源:scene > options(分享链接) + const uid = parseInt(sceneParams.uid || options.uid || 0, 10) + let eid = parseInt(sceneParams.eid || options.eid || 0, 10) + + // 兼容旧格式 scene: e_企业ID + if (!eid && rawScene && rawScene.indexOf('e_') === 0) { + eid = parseInt(rawScene.slice(2), 10) || 0 + } + + console.log('[enterprise/onLoad] 解码参数 =>', { + rawScene, + sceneParams, + options, + uid, + eid + }) + + if (eid > 0) app.globalData.enterpriseIdFromScene = eid + + // 企业版分销绑定:uid > 0 且 eid > 0 时触发 + if (uid > 0 && eid > 0) { + app.globalData._pendingInviterId = uid + app.globalData._pendingInviterScope = 'enterprise' + app.globalData._pendingInviterEid = eid + } + // 获取状态栏高度和屏幕信息 + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 0 + const screenWidth = systemInfo.screenWidth || 375 + const statusBarHeightRpx = (statusBarHeight * 750) / screenWidth + const navbarHeightRpx = statusBarHeightRpx + 88 + const gd = app.globalData + this.setData({ + statusBarHeight: statusBarHeightRpx, + navbarHeight: navbarHeightRpx, + siteTitle: gd.siteTitle || '神仙团队AI性格测试', + startButtonEnterprise: (gd.textConfig && gd.textConfig.startButtonEnterprise) || '开始面部测试', + aiAnalysisText: (gd.textConfig && gd.textConfig.aiAnalysisText) || '智能分析' + }) + + // 未绑定企业的用户:若从邀请码扫码进入(有 enterpriseIdFromScene)也允许使用企业版 + const userInfo = app.globalData.userInfo || wx.getStorageSync('userInfo') || {} + const fromInvite = !!app.globalData.enterpriseIdFromScene + const redirectBack = () => { + wx.showToast({ title: '您尚未绑定任何企业,无法使用企业版', icon: 'none', duration: 2500 }) + setTimeout(() => wx.switchTab({ url: '/pages/index/index' }), 600) + } + // 如果不是通过企业邀请码进入:沿用原有 hasEnterprise 判定 + if (!fromInvite) { + if (userInfo.hasEnterprise === true) { + app.getRuntimeConfig().then((cfg) => { + if (cfg && cfg.siteTitle) { + app.globalData.siteTitle = cfg.siteTitle + this.setData({ siteTitle: cfg.siteTitle }) + } + }).catch(() => {}) + return + } + if (userInfo.hasEnterprise === false) { + redirectBack() + return + } + } + app.ensureLogin() + .then(() => app.getRuntimeConfig()) + .then((cfg) => { + if (cfg) { + if (cfg.siteTitle) { + app.globalData.siteTitle = cfg.siteTitle + this.setData({ siteTitle: cfg.siteTitle }) + } + if (cfg.textConfig) { + app.globalData.textConfig = cfg.textConfig + this.setData({ + startButtonEnterprise: cfg.textConfig.startButtonEnterprise || '开始面部测试', + aiAnalysisText: cfg.textConfig.aiAnalysisText || '智能分析' + }) + } + } + if ((cfg && cfg.pricingType) !== 'enterprise' && !app.globalData.enterpriseIdFromScene) { + redirectBack() + return + } + // 若通过企业邀请码进入(带 enterpriseIdFromScene),登录后绑定到 wechat_users.enterpriseId + const eid = app.globalData.enterpriseIdFromScene + if (eid) { + request({ + url: '/api/enterprise/bind', + method: 'POST', + data: { enterpriseId: eid }, + success(res) { + if (res.statusCode === 200 && res.data && res.data.code === 200) { + const data = res.data.data || {} + const merged = { ...(app.globalData.userInfo || {}), ...data } + app.globalData.userInfo = merged + wx.setStorageSync('userInfo', merged) + } + } + }) + } + }) + .catch(() => { + wx.showToast({ title: '请先登录', icon: 'none' }) + setTimeout(() => wx.switchTab({ url: '/pages/index/index' }), 600) + }) + }, + + onShow() { + if (typeof this.getTabBar === 'function' && this.getTabBar()) { + this.getTabBar().setData({ selected: 0 }) + } + try { getApp().globalData.appScope = 'enterprise' } catch (e) {} + const gd = getApp().globalData + this.setData({ + siteTitle: gd.siteTitle || '神仙团队AI性格测试', + startButtonEnterprise: (gd.textConfig && gd.textConfig.startButtonEnterprise) || '开始面部测试', + aiAnalysisText: (gd.textConfig && gd.textConfig.aiAnalysisText) || '智能分析' + }) + }, + + // 切换到个人版 + switchToPersonal() { + wx.switchTab({ + url: '/pages/index/index' + }) + }, + + // 开始AI面部测试(先校验是否已上传简历,再跳转相机) + startAITest() { + const eid = (app.globalData && app.globalData.enterpriseIdFromScene) || (app.globalData && app.globalData.userInfo && app.globalData.userInfo.enterpriseId) || (wx.getStorageSync('userInfo') || {}).enterpriseId || null + const query = eid ? `?enterpriseId=${eid}&pageSize=1` : '?pageSize=1' + request({ + url: '/api/enterprise/resume-uploads' + query, + method: 'GET', + needAuth: true, + success: (res) => { + const list = (res.data && res.data.code === 200 && res.data.data && res.data.data.list) ? res.data.data.list : [] + if (!list.length) { + wx.showModal({ + title: '提示', + content: '需要先上传简历后再开始面部测试,请到「我的」-「我的简历」中上传', + showCancel: true, + confirmText: '去上传', + success: (r) => { + if (r.confirm) { + wx.navigateTo({ url: '/pages/enterprise/resume-history' }) + } + } + }) + return + } + wx.switchTab({ url: '/pages/index/camera' }) + }, + fail: () => { + wx.showModal({ + title: '提示', + content: '需要先上传简历后再开始面部测试,请到「我的」-「我的简历」中上传', + showCancel: true, + confirmText: '去上传', + success: (r) => { + if (r.confirm) { + wx.navigateTo({ url: '/pages/enterprise/resume-history' }) + } + } + }) + } + }) + }, + + onShareAppMessage() { + const { getSharePath } = require('../../utils/share') + return { + title: '神仙团队AI性格测试 (企业版) - 团队分析与优化', + path: getSharePath('/pages/enterprise/index') + } + }, + + onShareTimeline() { + const { buildShareQuery } = require('../../utils/share') + return { + title: '神仙团队AI性格测试 (企业版) - 团队分析与优化', + query: buildShareQuery() + } + } +}) diff --git a/miniprogram/pages/enterprise/index.json b/miniprogram/pages/enterprise/index.json new file mode 100644 index 0000000..c0684f0 --- /dev/null +++ b/miniprogram/pages/enterprise/index.json @@ -0,0 +1,7 @@ +{ + "navigationStyle": "custom", + "usingComponents": { + "custom-tab-bar": "/custom-tab-bar/index" + }, + "enablePullDownRefresh": false +} diff --git a/miniprogram/pages/enterprise/index.wxml b/miniprogram/pages/enterprise/index.wxml new file mode 100644 index 0000000..033a726 --- /dev/null +++ b/miniprogram/pages/enterprise/index.wxml @@ -0,0 +1,59 @@ + + + + + + + 👤 + 个人版 + + {{siteTitle || '神仙团队AI性格测试'}} + + + + + + + + + + + + + + 面相分析 + 骨相分析 + 性格测评 + + + + + 测试流程 + + + 1 + STEP1 + 拍摄照片 + + + + 2 + STEP2 + {{aiAnalysisText || '智能分析'}} + + + + 3 + STEP3 + 生成报告 + + + + + + {{startButtonEnterprise || '开始面部测试'}} + + + + + diff --git a/miniprogram/pages/enterprise/index.wxss b/miniprogram/pages/enterprise/index.wxss new file mode 100644 index 0000000..1617328 --- /dev/null +++ b/miniprogram/pages/enterprise/index.wxss @@ -0,0 +1,283 @@ +/* pages/enterprise/index.wxss - 企业版首页样式(与个人版相同,仅背景不同) */ +.container { + min-height: 100vh; + width: 100vw; + overflow-x: hidden; + overflow-y: auto; + background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 50%, #ffffff 100%); + position: relative; + display: flex; + flex-direction: column; + box-sizing: border-box; + padding-bottom: calc(100rpx + env(safe-area-inset-bottom) + 40rpx); +} + +/* 背景装饰圆形 */ +.bg-decoration { + position: absolute; + width: 600rpx; + height: 600rpx; + border-radius: 50%; + pointer-events: none; + z-index: 0; +} + +.bg-top-right { + top: -200rpx; + right: -200rpx; + background: radial-gradient(circle, rgba(139, 92, 246, 0.08) 0%, transparent 70%); +} + +.bg-bottom-left { + /* bottom: -200rpx; + left: -200rpx; */ + bottom: 0; + left: 0; + background: radial-gradient(circle, rgba(99, 102, 241, 0.06) 0%, transparent 70%); +} + +/* 自定义导航栏 */ +.custom-navbar { + position: fixed; + top: 0; + left: 0; + right: 0; + background: #ffffff; + z-index: 10000; + width: 100%; +} + +.navbar-content { + height: 88rpx; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 40rpx; + position: relative; + background: #ffffff; + min-height: 88rpx; +} + +.navbar-title { + position: absolute; + left: 50%; + transform: translateX(-50%); + font-size: 36rpx; + font-weight: 700; + color: #333; + text-align: center; + flex: 1; +} + +.navbar-placeholder { + width: 140rpx; + flex-shrink: 0; +} + +.switch-personal-btn { + display: flex; + align-items: center; + gap: 8rpx; + padding: 12rpx 20rpx; + background: rgba(99, 102, 241, 0.1); + border-radius: 30rpx; + border: 1rpx solid rgba(99, 102, 241, 0.2); + flex-shrink: 0; + z-index: 10; +} + +.personal-icon { + font-size: 28rpx; +} + +.personal-text { + font-size: 24rpx; + color: #6366f1; + font-weight: 600; +} + +.top-image-section { + width: 100%; + padding: 15rpx 40rpx 20rpx; + flex-shrink: 0; + box-sizing: border-box; + position: relative; + z-index: 1; + margin-top: 0; +} + +.image-container { + position: relative; + width: 100%; +} + +.image-wrapper { + position: relative; + width: 100%; + padding-top: 100%; + border-radius: 50%; + overflow: hidden; + background: #ffffff; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.12); +} + +.main-image { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 85%; + height: 85%; + display: block; +} + +.float-tag { + position: absolute; + padding: 12rpx 24rpx; + border-radius: 30rpx; + font-size: 24rpx; + font-weight: 600; + color: #e63946; + background: rgba(255, 255, 255, 0.95); + box-shadow: 0 4rpx 16rpx rgba(230, 57, 70, 0.2); + white-space: nowrap; + z-index: 10; +} + +.tag-1 { + top: 15%; + right: 10rpx; +} + +.tag-2 { + bottom: 25%; + left: 0; +} + +.tag-3 { + bottom: 25%; + right: 0; +} + +.process-section { + padding: 15rpx 40rpx 20rpx; + flex-shrink: 0; + position: relative; + z-index: 1; +} + +.section-title { + font-size: 32rpx; + font-weight: 700; + color: #e63946; + text-align: center; + margin-bottom: 25rpx; +} + +.process-steps { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 10rpx; +} + +.step-item { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; +} + +.step-circle { + width: 70rpx; + height: 70rpx; + border-radius: 50%; + background: #b8b9bc; + display: flex; + align-items: center; + justify-content: center; + font-size: 36rpx; + font-weight: 700; + color: #fff; + margin-bottom: 12rpx; +} + +.step-circle.active { + background: #e63946; +} + +.step-label { + font-size: 20rpx; + color: #666; + margin-bottom: 4rpx; +} + +.step-text { + font-size: 24rpx; + color: #333; + font-weight: 600; +} + +.step-line { + width: 50rpx; + height: 4rpx; + background: #e0e0e0; + margin: 0 6rpx 50rpx; +} + +.start-button { + /* 顶部与中间按钮的间距:上 20,下 10 */ + margin: 20rpx 40rpx 0; + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); + border-radius: 50rpx; + padding: 24rpx; + text-align: center; + box-shadow: 0 8rpx 30rpx rgba(99, 102, 241, 0.3); + position: relative; + z-index: 1; +} + +.button-text { + font-size: 30rpx; + color: #fff; + font-weight: 700; +} + +.upload-button { + margin: 10rpx 40rpx 0; + border: 2rpx solid #8b5cf6; + border-radius: 50rpx; + padding: 20rpx; + text-align: center; + background: #fff; + position: relative; + z-index: 1; +} + +.upload-button-primary { + margin: 20rpx 40rpx 0; + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); + border: none; +} + +.upload-button-primary .upload-text { + color: #fff; +} + +.upload-text { + font-size: 26rpx; + color: #8b5cf6; + font-weight: 600; +} + +/* 确保底部导航显示 */ +custom-tab-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 9999; +} diff --git a/miniprogram/pages/enterprise/resume-history.js b/miniprogram/pages/enterprise/resume-history.js new file mode 100644 index 0000000..cfdc18c --- /dev/null +++ b/miniprogram/pages/enterprise/resume-history.js @@ -0,0 +1,209 @@ +// pages/enterprise/resume-history.js - 简历上传记录(历史) +const app = getApp() +const { request } = require('../../utils/request') + +Page({ + data: { + list: [], + loading: true, + empty: false + }, + + onLoad() { + this.loadList() + }, + + onShow() { + if (this.data.list.length > 0 || !this.data.loading) { + this.loadList() + } + }, + + loadList() { + this.setData({ loading: true }) + const eid = (app.globalData && app.globalData.enterpriseIdFromScene) || (app.globalData && app.globalData.userInfo && app.globalData.userInfo.enterpriseId) || (wx.getStorageSync('userInfo') || {}).enterpriseId || null + const query = eid ? `?enterpriseId=${eid}&pageSize=100` : '?pageSize=100' + request({ + url: '/api/enterprise/resume-uploads' + query, + method: 'GET', + needAuth: true, + success: (res) => { + this.setData({ loading: false }) + if (res.statusCode === 200 && res.data && res.data.code === 200 && Array.isArray(res.data.data && res.data.data.list)) { + const list = (res.data.data.list || []).map((item) => ({ + id: item.id, + url: item.url || '', + fileName: item.fileName || '', + uploadedAt: item.uploadedAt || 0, + uploadedAtStr: item.uploadedAtStr || this._formatTime(item.uploadedAt), + isDefault: !!item.isDefault + })) + this.setData({ list, empty: list.length === 0 }) + } else { + this.setData({ list: [], empty: true }) + } + }, + fail: () => this.setData({ loading: false, list: [], empty: true }) + }) + }, + + uploadResume() { + const eid = (app.globalData && app.globalData.enterpriseIdFromScene) || (app.globalData && app.globalData.userInfo && app.globalData.userInfo.enterpriseId) || (wx.getStorageSync('userInfo') || {}).enterpriseId || null + wx.chooseMessageFile({ + count: 1, + type: 'file', + extension: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf', 'doc', 'docx'], + success: (fileRes) => { + const files = fileRes.tempFiles || [] + if (!files.length) { + wx.showToast({ title: '请选择文件', icon: 'none' }) + return + } + const file = files[0] + const filePath = file.path || file.tempFilePath + const fileName = file.name || '简历文件' + const apiBase = (app.globalData && app.globalData.apiBase) ? app.globalData.apiBase.replace(/\/$/, '') : '' + const token = (app.globalData && app.globalData.token) || wx.getStorageSync('token') || '' + const uploadUrl = apiBase + '/api/upload/file' + wx.showLoading({ title: '上传中...', mask: true }) + wx.uploadFile({ + url: uploadUrl, + filePath, + name: 'file', + header: token ? { Authorization: 'Bearer ' + token } : {}, + success: (res) => { + wx.hideLoading() + try { + const data = JSON.parse(res.data) + if (data.code === 200 && data.data && data.data.url) { + const url = data.data.url + request({ + url: '/api/enterprise/resume-uploads', + method: 'POST', + needAuth: true, + data: { url, fileName, enterpriseId: eid }, + success: (r) => { + if (r.statusCode === 200 && r.data && r.data.code === 200) { + wx.showToast({ title: '已上传并记录', icon: 'success' }) + this.loadList() + } else { + wx.showToast({ title: r.data && r.data.message || '记录失败', icon: 'none' }) + } + }, + fail: () => wx.showToast({ title: '记录失败,请重试', icon: 'none' }) + }) + } else { + wx.showToast({ title: data.message || '上传失败', icon: 'none' }) + } + } catch (e) { + wx.showToast({ title: '解析上传结果失败', icon: 'none' }) + } + }, + fail: () => { + wx.hideLoading() + wx.showToast({ title: '上传失败,请稍后重试', icon: 'none' }) + } + }) + }, + fail: () => wx.showToast({ title: '已取消选择', icon: 'none' }) + }) + }, + + _formatTime(ts) { + if (!ts) return '' + const d = new Date(ts) + 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') + return `${y}-${m}-${day} ${h}:${min}` + }, + + previewResume(e) { + const url = e.currentTarget.dataset.url + const fileName = e.currentTarget.dataset.fileName || '' + if (!url) return + const fullUrl = url.startsWith('http') ? url : ((app.globalData && app.globalData.apiBase) || '').replace(/\/$/, '') + url + const ext = (fileName.split('.').pop() || '').toLowerCase() + const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp'] + const isImage = imageExts.indexOf(ext) !== -1 + + wx.downloadFile({ + url: fullUrl, + success: (res) => { + if (res.statusCode !== 200 || !res.tempFilePath) { + wx.showToast({ title: '打开失败', icon: 'none' }) + return + } + const filePath = res.tempFilePath + if (isImage) { + wx.previewImage({ current: filePath, urls: [filePath] }) + } else { + const docTypes = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'pdf'] + const fileType = docTypes.indexOf(ext) !== -1 ? ext : 'pdf' + wx.openDocument({ + filePath, + fileType, + showMenu: true, + fail: () => wx.showToast({ title: '该格式暂不支持预览', icon: 'none' }) + }) + } + }, + fail: () => wx.showToast({ title: '打开失败', icon: 'none' }) + }) + }, + + setDefault(e) { + const id = e.currentTarget.dataset.id + if (!id) return + request({ + url: '/api/enterprise/resume-uploads/set-default', + method: 'POST', + needAuth: true, + data: { id }, + success: (res) => { + if (res.statusCode === 200 && res.data && res.data.code === 200) { + wx.showToast({ title: '已设为默认简历', icon: 'success' }) + this.loadList() + } else { + wx.showToast({ title: res.data && res.data.message || '设置失败', icon: 'none' }) + } + }, + fail: () => wx.showToast({ title: '设置失败', icon: 'none' }) + }) + }, + + deleteResume(e) { + const id = e.currentTarget.dataset.id + if (!id) return + wx.showModal({ + title: '确认删除', + content: '删除后不可恢复,确定删除该简历记录?', + confirmText: '删除', + confirmColor: '#f43f5e', + success: (res) => { + if (!res.confirm) return + request({ + url: '/api/enterprise/resume-uploads/delete', + method: 'POST', + needAuth: true, + data: { id }, + success: (r) => { + if (r.statusCode === 200 && r.data && r.data.code === 200) { + wx.showToast({ title: '已删除', icon: 'success' }) + this.loadList() + } else { + wx.showToast({ title: r.data && r.data.message || '删除失败', icon: 'none' }) + } + }, + fail: () => wx.showToast({ title: '删除失败', icon: 'none' }) + }) + } + }) + }, + + goBack() { + wx.navigateBack({ fail: () => wx.switchTab({ url: '/pages/profile/index' }) }) + } +}) diff --git a/miniprogram/pages/enterprise/resume-history.json b/miniprogram/pages/enterprise/resume-history.json new file mode 100644 index 0000000..9481ceb --- /dev/null +++ b/miniprogram/pages/enterprise/resume-history.json @@ -0,0 +1,6 @@ +{ + "navigationBarTitleText": "我的简历", + "navigationBarBackgroundColor": "#FFFFFF", + "navigationBarTextStyle": "black", + "usingComponents": {} +} diff --git a/miniprogram/pages/enterprise/resume-history.wxml b/miniprogram/pages/enterprise/resume-history.wxml new file mode 100644 index 0000000..dff647e --- /dev/null +++ b/miniprogram/pages/enterprise/resume-history.wxml @@ -0,0 +1,66 @@ + + + + + + + + + + 仅展示当前绑定企业的简历上传记录 + + + + + + + + 📄 + + + {{item.fileName}} + {{item.uploadedAtStr}} + + + 默认 + 设为默认 + + + + + + 删除 + + + + 预览查看 + + + + + + + + + + 📁 + + 暂无上传记录 + 点击下方按钮上传简历,或返回企业版首页上传 + 上传简历 + 返回首页 + + + + + 加载中... + + + + + + + 上传简历 + + + diff --git a/miniprogram/pages/enterprise/resume-history.wxss b/miniprogram/pages/enterprise/resume-history.wxss new file mode 100644 index 0000000..81b1085 --- /dev/null +++ b/miniprogram/pages/enterprise/resume-history.wxss @@ -0,0 +1,342 @@ +/* pages/enterprise/resume-history.wxss */ +.page { + min-height: 100vh; + background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 50%, #ffffff 100%); + position: relative; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +/* 背景装饰圆形 */ +.bg-decoration { + position: absolute; + width: 600rpx; + height: 600rpx; + border-radius: 50%; + pointer-events: none; + z-index: 0; +} + +.bg-top-right { + top: -200rpx; + right: -200rpx; + background: radial-gradient(circle, rgba(139, 92, 246, 0.08) 0%, transparent 70%); +} + +.bg-bottom-left { + bottom: 0; + left: 0; + background: radial-gradient(circle, rgba(99, 102, 241, 0.06) 0%, transparent 70%); +} + +.container { + position: relative; + z-index: 1; + flex: 1; + padding: 24rpx 32rpx 60rpx; + padding-bottom: calc(60rpx + 120rpx); +} + +/* 底部悬浮上传按钮(有数据时显示) */ +.float-upload-wrap { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 100; + padding: 24rpx 32rpx; + padding-bottom: calc(24rpx + env(safe-area-inset-bottom)); + background: linear-gradient(to top, rgba(255,255,255,0.98) 0%, rgba(255,255,255,0.95) 70%, transparent); +} + +.float-upload-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 12rpx; + height: 96rpx; + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); + color: #fff; + font-size: 30rpx; + font-weight: 700; + border-radius: 48rpx; + box-shadow: 0 8rpx 24rpx rgba(99, 102, 241, 0.35); +} + +.float-upload-btn:active { + opacity: 0.92; +} + +.float-upload-icon { + font-size: 36rpx; + line-height: 1; +} + +.float-upload-text { + letter-spacing: 1rpx; +} + +.tip-section { + margin-bottom: 24rpx; + padding: 0 8rpx; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 16rpx; +} + +.tip-text { + font-size: 24rpx; + color: #999; +} + +.upload-entry { + display: flex; + align-items: center; + gap: 8rpx; + padding: 14rpx 28rpx; + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); + color: #fff; + font-size: 26rpx; + font-weight: 600; + border-radius: 32rpx; + box-shadow: 0 6rpx 16rpx rgba(99, 102, 241, 0.25); +} + +.upload-entry:active { + opacity: 0.9; + transform: scale(0.98); +} + +.upload-entry-icon { + font-size: 28rpx; + line-height: 1; +} + +/* 简历卡片 */ +.resume-card { + background: #fff; + border-radius: 24rpx; + padding: 28rpx; + margin-bottom: 24rpx; + box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.04); + border: 1rpx solid rgba(255, 255, 255, 0.8); +} + +.file-info { + display: flex; + align-items: flex-start; + gap: 20rpx; + margin-bottom: 28rpx; + position: relative; + overflow: hidden; + min-height: 0; +} + +.file-icon-wrap { + width: 80rpx; + height: 80rpx; + background: #f0f2ff; + border-radius: 16rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.file-icon { + font-size: 40rpx; +} + +.file-meta { + width: 80%; + min-width: 0; + overflow: hidden; + padding-right: 16rpx; +} + +.top-right-actions { + flex-shrink: 0; + align-self: flex-start; +} + +.set-default-text { + font-size: 24rpx; + color: #6366f1; + padding: 4rpx 0; +} + +.set-default-text:active { + opacity: 0.8; +} + +.name { + display: block; + font-size: 30rpx; + font-weight: 600; + color: #333; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-bottom: 6rpx; + width: 85%; +} + +.time { + display: block; + font-size: 24rpx; + color: #999; +} + +.default-badge { + position: absolute; + top: 0; + right: 0; + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); + color: #fff; + font-size: 20rpx; + font-weight: 600; + padding: 4rpx 16rpx; + border-radius: 10rpx; +} + +/* 卡片操作区 */ +.card-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 24rpx; + border-top: 1rpx solid #f5f6fa; +} + +.secondary-actions { + display: flex; + align-items: center; + gap: 20rpx; +} + +.action-btn { + display: flex; + align-items: center; + justify-content: center; + height: 64rpx; + border-radius: 32rpx; + font-size: 24rpx; + font-weight: 600; + transition: all 0.2s ease; +} + +.action-btn:active { + opacity: 0.7; + transform: scale(0.98); +} + +.text-btn { + padding: 0 16rpx; + background: transparent; +} + +.action-icon { + margin-right: 6rpx; + font-size: 26rpx; +} + +.delete-btn { + color: #f43f5e; + font-size: 26rpx; +} + + +.primary-btn { + padding: 0 32rpx; + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); + color: #fff; + box-shadow: 0 4rpx 12rpx rgba(99, 102, 241, 0.2); +} + +/* 空状态 */ +.empty-state { + padding: 120rpx 60rpx; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.empty-icon-wrap { + width: 160rpx; + height: 160rpx; + background: #fff; + border-radius: 80rpx; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 40rpx; + box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.05); +} + +.empty-icon { + font-size: 80rpx; +} + +.empty-text { + font-size: 34rpx; + font-weight: 700; + color: #333; + margin-bottom: 16rpx; +} + +.empty-tip { + font-size: 26rpx; + color: #999; + line-height: 1.6; + margin-bottom: 48rpx; +} + +.go-upload-btn { + padding: 24rpx 64rpx; + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); + color: #fff; + font-size: 28rpx; + font-weight: 700; + border-radius: 50rpx; + box-shadow: 0 10rpx 20rpx rgba(99, 102, 241, 0.2); + margin-bottom: 20rpx; +} + +.back-home-btn { + padding: 20rpx 48rpx; + font-size: 26rpx; + color: #6366f1; + font-weight: 600; + border: 2rpx solid #6366f1; + border-radius: 40rpx; +} + +/* 加载中 */ +.loading-wrap { + padding: 100rpx 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 20rpx; +} + +.loading-spinner { + width: 40rpx; + height: 40rpx; + border: 4rpx solid rgba(99, 102, 241, 0.1); + border-top-color: #6366f1; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.loading-text { + font-size: 26rpx; + color: #999; +} diff --git a/miniprogram/pages/history/index.js b/miniprogram/pages/history/index.js new file mode 100644 index 0000000..fb8e497 --- /dev/null +++ b/miniprogram/pages/history/index.js @@ -0,0 +1,161 @@ +// pages/history/index.js - 测试历史记录(一次拉取全部,时间行右侧展示企业名) +const app = getApp() + +Page({ + data: { + activeTab: 'all', + tabName: '', + list: [], + total: 0, + loading: false, + isEnterprise: false + }, + + _checkIsEnterprise() { + const gd = app.globalData || {} + const storedUser = wx.getStorageSync('userInfo') || null + const scope = gd.appScope || 'personal' + const enterpriseId = gd.enterpriseIdFromScene + || (gd.userInfo && gd.userInfo.enterpriseId) + || (storedUser && storedUser.enterpriseId) + || null + return scope === 'enterprise' || !!enterpriseId + }, + + onLoad() { + this.setData({ isEnterprise: this._checkIsEnterprise() }) + this.loadAll() + }, + + onShow() { + this.setData({ isEnterprise: this._checkIsEnterprise() }) + this.loadAll() + if (typeof this.getTabBar === 'function' && this.getTabBar()) { + this.getTabBar().setData({ selected: 1 }) + } + }, + + // 一次拉取全部历史(pageSize=500) + loadAll() { + if (this.data.loading) return + this.setData({ loading: true, list: [] }) + + const token = app.globalData.token || wx.getStorageSync('token') + const apiBase = app.globalData.apiBase + const { activeTab } = this.data + const typeParam = activeTab === 'all' ? '' : `&type=${activeTab}` + + if (!token || !apiBase) { + this.setData({ loading: false }) + this.loadFromStorage() + return + } + + wx.request({ + url: `${apiBase}/api/test/history?page=1&pageSize=500${typeParam}&scope=all`, + method: 'GET', + header: { Authorization: `Bearer ${token}` }, + success: (res) => { + if (res.statusCode === 200 && res.data && res.data.data) { + const payload = res.data.data + const rawList = Array.isArray(payload) ? payload : (payload.list || []) + const total = Array.isArray(payload) ? rawList.length : (payload.total || 0) + const formatted = this.formatList(rawList) + this.setData({ list: formatted, total, loading: false }) + } else { + this.setData({ loading: false }) + this.loadFromStorage() + } + }, + fail: () => { + this.setData({ loading: false }) + this.loadFromStorage() + } + }) + }, + + formatList(rawList) { + const typeNames = { mbti: 'MBTI性格测试', disc: 'DISC性格测试', pdp: 'PDP行为偏好测试', ai: '面相分析', resume: '简历综合分析' } + const emojis = { mbti: '🧠', disc: '📊', pdp: '🦁', ai: '👁️', resume: '📋' } + + return rawList.map((item, idx) => { + if (item.typeName) { + return { ...item, enterpriseName: item.enterpriseName || '' } + } + const testType = (item.testType || item.type || 'mbti').toLowerCase() + const ts = item.createdAt || item.testTime || item.timestamp + return { + ...item, + type: testType, + key: testType + '_' + (item.id || idx), + emoji: emojis[testType] || '📋', + typeName: typeNames[testType] || '测试', + testTime: ts ? this.formatTime(typeof ts === 'number' ? ts * 1000 : ts) : '', + enterpriseName: item.enterpriseName || '' + } + }) + }, + + // 本地缓存回退 + loadFromStorage() { + const mbtiResult = wx.getStorageSync('mbtiResult') + const discResult = wx.getStorageSync('discResult') + const pdpResult = wx.getStorageSync('pdpResult') + const aiResult = wx.getStorageSync('aiResult') + const list = [] + if (mbtiResult) list.push({ type: 'mbti', key: 'mbti', emoji: '🧠', typeName: 'MBTI性格测试', resultText: mbtiResult.mbtiType || '未知', testTime: this.formatTime(mbtiResult.timestamp), data: mbtiResult }) + if (pdpResult) list.push({ type: 'pdp', key: 'pdp', emoji: pdpResult.description?.emoji || '🦁', typeName: 'PDP行为偏好测试', resultText: pdpResult.description?.type || '未知', testTime: this.formatTime(pdpResult.timestamp || pdpResult.completedAt), data: pdpResult }) + if (discResult) list.push({ type: 'disc', key: 'disc', emoji: '📊', typeName: 'DISC性格测试', resultText: (discResult.dominantType || '未知') + '型', testTime: this.formatTime(discResult.timestamp || discResult.completedAt), data: discResult }) + if (aiResult) list.push({ type: 'ai', key: 'ai', emoji: '👁️', typeName: '面相分析', resultText: aiResult.mbti || '未知', testTime: this.formatTime(aiResult.timestamp || aiResult.completedAt), data: aiResult }) + this.setData({ list, total: list.length, loading: false }) + }, + + changeTab(e) { + const tab = e.currentTarget.dataset.tab + const names = { all: '', mbti: 'MBTI', pdp: 'PDP', disc: 'DISC', ai: '面相', resume: '简历' } + this.setData({ activeTab: tab, tabName: names[tab] || '' }) + this.loadAll() + }, + + formatTime(timestamp) { + if (!timestamp) return '未知时间' + const date = new Date(timestamp) + const y = date.getFullYear() + const m = String(date.getMonth() + 1).padStart(2, '0') + const d = String(date.getDate()).padStart(2, '0') + const h = String(date.getHours()).padStart(2, '0') + const min = String(date.getMinutes()).padStart(2, '0') + return `${y}-${m}-${d} ${h}:${min}` + }, + + viewDetail(e) { + const type = e.currentTarget.dataset.type + const id = e.currentTarget.dataset.id + const routes = { + mbti: '/pages/result/mbti', + disc: '/pages/result/disc', + pdp: '/pages/result/pdp', + ai: '/pages/index/result', + resume: '/pages/result/resume' + } + const base = routes[type] + if (!base) return + if ((type === 'ai' || type === 'resume') && !id) return + const query = id ? `?id=${id}&type=${type}` : '' + wx.navigateTo({ url: query ? base + query : base }) + }, + + goToTest() { + const routes = { + mbti: '/pages/test/mbti', + disc: '/pages/test/disc', + pdp: '/pages/test/pdp', + ai: '/pages/index/camera', + resume: '/pages/enterprise/index' + } + const url = routes[this.data.activeTab] + if (url) wx.navigateTo({ url }) + else wx.switchTab({ url: '/pages/index/index' }) + }, + +}) diff --git a/miniprogram/pages/history/index.json b/miniprogram/pages/history/index.json new file mode 100644 index 0000000..30f7c36 --- /dev/null +++ b/miniprogram/pages/history/index.json @@ -0,0 +1,6 @@ +{ + "navigationBarTitleText": "测试记录", + "navigationBarBackgroundColor": "#FFFFFF", + "navigationBarTextStyle": "black", + "usingComponents": {} +} diff --git a/miniprogram/pages/history/index.wxml b/miniprogram/pages/history/index.wxml new file mode 100644 index 0000000..552ba42 --- /dev/null +++ b/miniprogram/pages/history/index.wxml @@ -0,0 +1,74 @@ + + + + + + + + 全部 + + + MBTI + + + PDP + + + DISC + + + 面相 + + + 简历 + + + + + + + + 📋 + 暂无{{activeTab === 'all' ? '' : tabName}}历史记录 + + 开始测试 + + + + + + + + + {{item.emoji}} + + + + {{item.typeName}} + + {{item.resultText}} + + + + {{item.testTime}} + {{item.enterpriseName}} + + + + + + + + 加载中... + + + + 共 {{total}} 条 + + + + + + 加载中... + + diff --git a/miniprogram/pages/history/index.wxss b/miniprogram/pages/history/index.wxss new file mode 100644 index 0000000..a4f44e7 --- /dev/null +++ b/miniprogram/pages/history/index.wxss @@ -0,0 +1,208 @@ +/* pages/history/index.wxss - 测试历史记录(按旧版模板重构) */ +.container { + min-height: 100vh; + background-color: #f5f5f5; +} + +/* Tabs */ +.tabs-container { + background-color: #fff; + border-bottom: 1rpx solid #f0f0f0; + padding: 20rpx 0; +} + +.tabs-scroll { + white-space: nowrap; +} + +.tabs { + display: flex; + padding: 0 32rpx; +} + +.tab-item { + padding: 12rpx 32rpx; + margin-right: 16rpx; + border-radius: 40rpx; + background-color: #f5f5f5; + flex-shrink: 0; +} + +.tab-item.active { + background-color: #e63946; +} + +.tab-text { + font-size: 28rpx; + color: #666; +} + +.tab-item.active .tab-text { + color: #fff; + font-weight: 500; +} + +/* 空状态 */ +.empty-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding-top: 200rpx; +} + +.empty-icon { + font-size: 100rpx; + margin-bottom: 24rpx; +} + +.empty-text { + font-size: 28rpx; + color: #999; + margin-bottom: 32rpx; +} + +.primary-button { + background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%); + border-radius: 40rpx; + padding: 20rpx 60rpx; + box-shadow: 0 8rpx 24rpx rgba(230, 57, 70, 0.3); +} + +.button-text { + font-size: 28rpx; + color: #fff; + font-weight: 500; +} + +/* 测试记录列表 */ +.test-list { + display: flex; + flex-direction: column; + gap: 20rpx; + padding: 20rpx; +} + +.test-card { + background-color: #fff; + border-radius: 16rpx; + padding: 24rpx; + box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05); +} + +.test-card:active { + transform: scale(0.98); + opacity: 0.9; +} + +.card-content { + display: flex; + align-items: center; +} + +.icon-circle { + width: 72rpx; + height: 72rpx; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-right: 20rpx; + flex-shrink: 0; +} + +.icon-mbti { background-color: #f3e8ff; } +.icon-pdp { background-color: #dbeafe; } +.icon-disc { background-color: #dcfce7; } +.icon-ai { background-color: #fce7f3; } + +.icon-emoji { + font-size: 32rpx; +} + +.test-details { + flex: 1; + min-width: 0; +} + +.test-header-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8rpx; + gap: 12rpx; +} + +.test-title { + font-size: 30rpx; + font-weight: 500; + color: #333; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.result-badge { + padding: 4rpx 16rpx; + border-radius: 20rpx; + flex-shrink: 0; +} + +.badge-mbti { background-color: #f3e8ff; } +.badge-mbti .badge-text { color: #7c3aed; } + +.badge-pdp { background-color: #dbeafe; } +.badge-pdp .badge-text { color: #2563eb; } + +.badge-disc { background-color: #dcfce7; } +.badge-disc .badge-text { color: #16a34a; } + +.badge-ai { background-color: #fce7f3; } +.badge-ai .badge-text { color: #db2777; } + +.badge-text { + font-size: 22rpx; + font-weight: 500; + white-space: nowrap; +} + +.test-time-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16rpx; + margin-top: 4rpx; +} + +.test-time { + font-size: 24rpx; + color: #999; + flex-shrink: 0; +} + +.test-enterprise { + font-size: 24rpx; + color: #666; + flex-shrink: 0; + max-width: 280rpx; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.load-more-row { + display: flex; + justify-content: center; + align-items: center; + padding: 32rpx 0 20rpx; +} + +.load-more-text { + font-size: 26rpx; + color: #7c3aed; +} + +.no-more { + color: #bbb; +} diff --git a/miniprogram/pages/index/camera.js b/miniprogram/pages/index/camera.js new file mode 100644 index 0000000..5420d82 --- /dev/null +++ b/miniprogram/pages/index/camera.js @@ -0,0 +1,196 @@ +// pages/index/camera.js - 面相分析拍照页,拍完后上传到服务器再跳转结果页 +const app = getApp() +const { hasPhone, bindPhoneByCode, ensureProfileCompleteAndRedirect } = require('../../utils/phoneAuth.js') + +Page({ + data: { + photos: [], + photoIndex: 0, + guideTexts: ['请正对镜头', '请向左转45°', '请向右转45°'], + guideText: '请正对镜头', + uploading: false, + needPhoneAuth: false, + aiAnalysisText: '智能分析' + }, + + onLoad() { + this.cameraContext = wx.createCameraContext() + const tc = app.globalData.textConfig + if (tc && tc.aiAnalysisText) { + this.setData({ aiAnalysisText: tc.aiAnalysisText }) + } else { + app.getRuntimeConfig().then((cfg) => { + if (cfg && cfg.textConfig) { + app.globalData.textConfig = cfg.textConfig + this.setData({ aiAnalysisText: cfg.textConfig.aiAnalysisText || '智能分析' }) + } + }).catch(() => {}) + } + }, + + onShow() { + if (!ensureProfileCompleteAndRedirect()) return + if (typeof this.getTabBar === 'function' && this.getTabBar()) { + this.getTabBar().setData({ selected: 1 }) + } + this.setData({ needPhoneAuth: !hasPhone() }) + const tc = app.globalData.textConfig + if (tc && tc.aiAnalysisText) { + this.setData({ aiAnalysisText: tc.aiAnalysisText }) + } + }, + + // 本页就地授权手机号 + onGetPhoneNumber(e) { + const { code, errMsg } = e.detail || {} + if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) { + wx.showToast({ title: '需要授权手机号才能继续', icon: 'none' }) + return + } + if (!code && !hasPhone()) { + wx.showToast({ title: '获取手机号失败', icon: 'none' }) + return + } + if (!code && hasPhone()) { + // 已有手机号,无需重复请求 + this.setData({ needPhoneAuth: false }) + return + } + bindPhoneByCode(code).then(() => { + this.setData({ needPhoneAuth: false }) + }).catch(() => { + // 失败时保持 needPhoneAuth 为 true,等待用户重新授权 + this.setData({ needPhoneAuth: !hasPhone() }) + }) + }, + + // 拍照 + takePhoto() { + if (this.data.photos.length >= 3) { + wx.showToast({ title: '已拍满3张', icon: 'none' }) + return + } + + this.cameraContext.takePhoto({ + quality: 'high', + success: (res) => { + const photos = [...this.data.photos, res.tempImagePath] + const photoIndex = photos.length + const guideText = this.data.guideTexts[photoIndex] || '拍摄完成' + + this.setData({ + photos, + photoIndex, + guideText + }) + + if (photos.length === 3) { + wx.showToast({ title: '拍摄完成', icon: 'success' }) + } + }, + fail: (err) => { + wx.showToast({ title: '拍照失败', icon: 'none' }) + console.error('拍照失败:', err) + } + }) + }, + + // 重新拍摄全部照片 + retakeAll() { + wx.showModal({ + title: '重新拍摄', + content: '确定要重新拍摄所有照片吗?', + confirmText: '确定', + cancelText: '取消', + success: (res) => { + if (res.confirm) { + this.setData({ + photos: [], + photoIndex: 0, + guideText: '请正对镜头' + }) + wx.showToast({ title: '已清空,请重新拍摄', icon: 'success' }) + } + } + }) + }, + + // 完成拍照:先上传 3 张图到服务器,拿到 URL 后再跳转结果页 + completeCapture() { + if (!ensureProfileCompleteAndRedirect()) return + if (!hasPhone()) { + wx.showToast({ title: '请先授权手机号', icon: 'none' }) + this.setData({ needPhoneAuth: true }) + return + } + const photos = this.data.photos + if (!photos || photos.length === 0) { + wx.showToast({ title: '请先拍摄照片', icon: 'none' }) + return + } + this.setData({ uploading: true }) + wx.showLoading({ title: '上传中...', mask: true }) + const apiBase = (app.globalData && app.globalData.apiBase) ? app.globalData.apiBase.replace(/\/$/, '') : '' + const token = (app.globalData && app.globalData.token) || wx.getStorageSync('token') || '' + const uploadUrl = apiBase + '/api/upload/image' + + const uploadOne = (path) => { + return new Promise((resolve, reject) => { + wx.uploadFile({ + url: uploadUrl, + filePath: path, + name: 'file', + header: token ? { Authorization: 'Bearer ' + token } : {}, + success: (res) => { + try { + const data = JSON.parse(res.data) + if (data.code === 200 && data.data && data.data.url) { + resolve(data.data.url) + } else { + reject(new Error(data.message || '上传失败')) + } + } catch (e) { + reject(new Error('解析上传结果失败')) + } + }, + fail: (err) => reject(err) + }) + }) + } + + Promise.all(photos.map(uploadOne)) + .then((urls) => { + wx.hideLoading() + wx.setStorageSync('aiPhotos', urls) + this.setData({ uploading: false }) + wx.navigateTo({ url: '/pages/index/result' }) + }) + .catch((err) => { + wx.hideLoading() + this.setData({ uploading: false }) + wx.showToast({ title: err.message || '上传失败', icon: 'none' }) + }) + }, + + // 从相册选择:跳转到上传页(三角度上传),不改变当前 scope/企业上下文 + goToUpload() { + wx.navigateTo({ + url: '/pages/index/upload' + }) + }, + + // 相机错误 + onCameraError(e) { + console.error('相机错误:', e) + wx.showModal({ + title: '相机权限', + content: '请允许使用相机权限以进行AI人脸分析', + confirmText: '去设置', + success: (res) => { + if (res.confirm) { + wx.openSetting() + } + } + }) + } +}) diff --git a/miniprogram/pages/index/camera.json b/miniprogram/pages/index/camera.json new file mode 100644 index 0000000..988934d --- /dev/null +++ b/miniprogram/pages/index/camera.json @@ -0,0 +1,6 @@ +{ + "navigationBarTitleText": "拍照", + "navigationBarBackgroundColor": "#FFFFFF", + "navigationBarTextStyle": "black", + "usingComponents": {} +} diff --git a/miniprogram/pages/index/camera.wxml b/miniprogram/pages/index/camera.wxml new file mode 100644 index 0000000..259ce7c --- /dev/null +++ b/miniprogram/pages/index/camera.wxml @@ -0,0 +1,69 @@ + + + + + 步骤 {{photoIndex + 1}}/3 + {{photos.length}}/3 张照片已完成 + + + + + + + + + {{photoIndex + 1}} + + {{guideText}} + 请保持自然表情,确保光线充足 + + + + + + + + + + + + + + {{index === 0 ? '正面' : (index === 1 ? '左侧' : '右侧')}} + + + + + + + + 拍摄{{guideText}}照片 + + + 从相册选择 + + + + + + 重新拍摄 + + + 立即{{aiAnalysisText || '智能分析'}} + + + + + + + 为保障服务与联系,请先授权手机号。 + + + diff --git a/miniprogram/pages/index/camera.wxss b/miniprogram/pages/index/camera.wxss new file mode 100644 index 0000000..60525fb --- /dev/null +++ b/miniprogram/pages/index/camera.wxss @@ -0,0 +1,312 @@ +/* pages/index/camera.wxss - 一屏内展示,为底部自定义 tabBar(含中间浮起圆钮)预留空间 */ +.container { + width: 100%; + height: 100vh; + height: 100dvh; + box-sizing: border-box; + display: flex; + flex-direction: column; + overflow: hidden; + background-color: #fff; + /* 预留底部空间,避免与自定义 tabBar(约 100rpx 高 + 中间圆钮上浮约 56rpx)重叠 */ + padding-bottom: calc(160rpx + env(safe-area-inset-bottom)); +} + +.progress-section { + flex-shrink: 0; + margin: 16rpx 24rpx 0; + padding: 20rpx 32rpx 16rpx; + border-radius: 10rpx; + background: linear-gradient(to right, #fff5f5, #ffe5e8); +} + +.progress-info { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10rpx; +} + +.step-text { + font-size: 26rpx; + font-weight: 600; + color: #e63946; +} + +.photo-count { + font-size: 22rpx; + color: #999; +} + +.progress-bars { + display: flex; + gap: 12rpx; + margin-bottom: 12rpx; +} + +.progress-bar { + flex: 1; + height: 6rpx; + border-radius: 6rpx; +} + +.progress-bar.completed { + background-color: #52c41a; +} + +.progress-bar.pending { + background-color: #e5e5e5; +} + +.instruction-card { + background: rgba(255, 255, 255, 0.9); + border-radius: 12rpx; + padding: 14rpx 18rpx; + border: 1rpx solid rgba(230, 57, 70, 0.1); +} + +.instruction-content { + display: flex; + align-items: center; + gap: 12rpx; +} + +.step-number { + width: 40rpx; + height: 40rpx; + flex-shrink: 0; + border-radius: 50%; + background-color: #e63946; + color: #fff; + font-size: 24rpx; + font-weight: bold; + display: flex; + align-items: center; + justify-content: center; +} + +.instruction-text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2rpx; +} + +.angle-text { + font-size: 26rpx; + font-weight: 600; + color: #c41d2a; +} + +.tip-text { + font-size: 22rpx; + color: #666; + line-height: 1.3; +} + +.camera-container { + flex: 1; + min-height: 0; + padding: 16rpx 24rpx; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; +} + +.camera-preview { + width: 100%; + max-width: 100%; + max-height: 100%; + aspect-ratio: 1; + border-radius: 32rpx; + overflow: hidden; + border: 6rpx solid #e5e5e5; + background-color: #000; + position: relative; +} + +.camera { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.photos-preview { + width: 100%; + height: 100%; + min-height: 0; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 12rpx; +} + +.photo-item { + position: relative; + flex: 1; + min-width: 0; + height: 100%; + max-height: 100%; + border-radius: 16rpx; + overflow: hidden; + border: 4rpx solid #e5e5e5; +} + +.photo-image { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +.photo-label { + position: absolute; + top: 8rpx; + left: 8rpx; + background: rgba(230, 57, 70, 0.9); + color: #fff; + font-size: 20rpx; + font-weight: 600; + padding: 4rpx 12rpx; + border-radius: 12rpx; +} + +.button-container { + flex-shrink: 0; + padding: 16rpx 24rpx 0; + display: flex; + justify-content: center; + align-items: center; +} + +.capture-actions { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 20rpx; +} + +.capture-actions .capture-button { + width: 100%; +} + +.album-button { + width: 100%; + padding: 20rpx; + border-radius: 50rpx; + text-align: center; + background: #fff; + border: 2rpx solid #e63946; + box-sizing: border-box; +} + +.album-button:active { + background: #fff5f5; +} + +.album-button-text { + font-size: 28rpx; + font-weight: 600; + color: #e63946; + letter-spacing: 1rpx; +} + +.phone-auth-section { + padding: 24rpx 24rpx 0; + display: flex; + flex-direction: column; + gap: 16rpx; +} + +.phone-auth-tip { + font-size: 26rpx; + color: #4b5563; +} + +.phone-auth-btn { + height: 80rpx; + line-height: 80rpx; + border-radius: 40rpx; + background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%); + color: #ffffff; + font-size: 28rpx; +} + +.phone-auth-btn::after { + border: none; +} + +.capture-button { + width: 100%; + max-width: 100%; + padding: 22rpx; + background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%); + border-radius: 50rpx; + text-align: center; + box-sizing: border-box; + box-shadow: 0 8rpx 24rpx rgba(230, 57, 70, 0.3); +} + +.button-text { + font-size: 30rpx; + font-weight: 700; + color: #fff; + letter-spacing: 1rpx; +} + +.action-buttons { + display: flex; + gap: 20rpx; + width: 100%; +} + +.action-button { + flex: 1; + padding: 28rpx 24rpx; + border-radius: 50rpx; + text-align: center; + box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1); + transition: all 0.3s ease; +} + +.action-button.secondary { + background: #fff; + border: 2rpx solid #e63946; +} + +.action-button.secondary:active { + background: #fff5f5; + transform: scale(0.98); +} + +.action-button.primary { + background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%); + box-shadow: 0 8rpx 24rpx rgba(230, 57, 70, 0.4); +} + +.action-button.primary:active { + transform: scale(0.98); + box-shadow: 0 4rpx 16rpx rgba(230, 57, 70, 0.3); +} + +.action-button-text { + font-size: 28rpx; + font-weight: 700; + letter-spacing: 1rpx; +} + +.action-button.secondary .action-button-text { + color: #e63946; +} + +.action-button.primary .action-button-text { + color: #fff; +} diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js new file mode 100644 index 0000000..c89d965 --- /dev/null +++ b/miniprogram/pages/index/index.js @@ -0,0 +1,177 @@ +// pages/index/index.js +const app = getApp() +const { request } = require('../../utils/request.js') + +Page({ + data: { + statusBarHeight: 0, + navbarHeight: 88, + showEnterpriseEntry: false, + siteTitle: '神仙团队AI性格测试', + startButtonText: '开始面相测试', + aiAnalysisText: '智能分析' + }, + + onLoad(options) { + // 获取状态栏高度和屏幕信息 + const systemInfo = wx.getSystemInfoSync() + const statusBarHeight = systemInfo.statusBarHeight || 0 + const screenWidth = systemInfo.screenWidth || 375 + // 将状态栏高度从px转换为rpx + const statusBarHeightRpx = (statusBarHeight * 750) / screenWidth + const navbarHeightRpx = statusBarHeightRpx + 88// 状态栏 + 导航栏内容 + + const userInfo = getApp().globalData.userInfo || wx.getStorageSync('userInfo') || {} + const gd = getApp().globalData + this.setData({ + statusBarHeight: statusBarHeightRpx, + navbarHeight: navbarHeightRpx, + showEnterpriseEntry: userInfo.hasEnterprise === true, + siteTitle: gd.siteTitle || '神仙团队AI性格测试', + startButtonText: (gd.textConfig && gd.textConfig.startButtonText) || '开始面相测试', + aiAnalysisText: (gd.textConfig && gd.textConfig.aiAnalysisText) || '智能分析' + }) + // 预加载站点名称与文案配置 + app.getRuntimeConfig().then((cfg) => { + if (cfg) { + if (cfg.siteTitle) { + getApp().globalData.siteTitle = cfg.siteTitle + this.setData({ siteTitle: cfg.siteTitle }) + } + if (cfg.textConfig) { + getApp().globalData.textConfig = cfg.textConfig + this.setData({ + startButtonText: cfg.textConfig.startButtonText || '开始面相测试', + aiAnalysisText: cfg.textConfig.aiAnalysisText || '智能分析' + }) + } + } + }).catch(() => {}) + // ── 解析入参(兼容扫码 scene / 分享链接 options)── + const rawScene = (options && options.scene) ? decodeURIComponent(options.scene) : '' + const sceneParams = {} + if (rawScene) { + rawScene.split('&').forEach(pair => { + const [k, v] = pair.split('=') + if (k) sceneParams[k] = v || '' + }) + } + + const uid = parseInt(sceneParams.uid || (options && options.uid) || 0, 10) + const eid = parseInt(sceneParams.eid || (options && options.eid) || 0, 10) + + console.log('[index/onLoad] 解码参数 =>', { + rawScene, + sceneParams, + options, + uid, + eid + }) + + // 携带 eid:跳转企业版首页 + if (eid > 0) { + getApp().globalData.enterpriseIdFromScene = eid + wx.navigateTo({ url: '/pages/enterprise/index?uid=' + uid + '&eid=' + eid }) + return + } + + // 个人版分销绑定 + if (uid > 0) { + app.globalData._pendingInviterId = uid + app.globalData._pendingInviterScope = 'personal' + } + }, + + onShow() { + if (typeof this.getTabBar === 'function' && this.getTabBar()) { + this.getTabBar().setData({ selected: 0 }) + } + // 个人版首页:固定 scope=personal,并清除企业来源上下文 + try { + getApp().globalData.appScope = 'personal' + getApp().globalData.enterpriseIdFromScene = null + } catch (e) {} + const gd = getApp().globalData + this.setData({ + siteTitle: gd.siteTitle || '神仙团队AI性格测试', + startButtonText: (gd.textConfig && gd.textConfig.startButtonText) || '开始面相测试', + aiAnalysisText: (gd.textConfig && gd.textConfig.aiAnalysisText) || '智能分析' + }) + const userInfo = getApp().globalData.userInfo || wx.getStorageSync('userInfo') || {} + this.setData({ showEnterpriseEntry: userInfo.hasEnterprise === true }) + // 有 token 时拉取最新用户信息(含 hasEnterprise),绑定企业后无需重新登录即可展示企业版入口 + const token = getApp().globalData.token || wx.getStorageSync('token') + if (token) { + request({ + url: '/api/auth/me', + method: 'GET', + success: (res) => { + if (res.statusCode === 200 && res.data && res.data.code === 200 && res.data.data) { + const user = res.data.data + getApp().globalData.userInfo = user + wx.setStorageSync('userInfo', user) + this.setData({ showEnterpriseEntry: user.hasEnterprise === true }) + } + } + }) + } + }, + + // 开始拍照(个人版入口:强制本次链路为个人定价) + startCamera() { + try { getApp().globalData.appScope = 'personal' } catch (e) {} + wx.switchTab({ + url: '/pages/index/camera' + }) + }, + + // 上传照片(个人版入口:强制本次链路为个人定价) + uploadPhoto() { + try { getApp().globalData.appScope = 'personal' } catch (e) {} + // 这里仅负责跳转到全新的「拍摄或上传照片」页面,具体拍摄/上传逻辑在新页面实现 + wx.navigateTo({ + url: '/pages/index/upload' + }) + }, + + // 切换到企业版(仅已绑定企业的用户可进入,优先用登录返回的 hasEnterprise,避免多请求) + switchToEnterprise() { + const app = getApp() + const userInfo = app.globalData.userInfo || wx.getStorageSync('userInfo') || {} + if (userInfo.hasEnterprise === true) { + wx.navigateTo({ url: '/pages/enterprise/index' }) + return + } + if (userInfo.hasEnterprise === false) { + wx.showToast({ title: '您尚未绑定任何企业,无法使用企业版', icon: 'none', duration: 2500 }) + return + } + // 缓存里没有 hasEnterprise 时:临时用 enterprise scope 获取配置判定 + try { app.globalData.appScope = 'enterprise' } catch (e) {} + app.ensureLogin().then(() => app.getRuntimeConfig()).then((cfg) => { + if ((cfg && cfg.pricingType) === 'enterprise') { + wx.navigateTo({ url: '/pages/enterprise/index' }) + } else { + wx.showToast({ title: '您尚未绑定任何企业,无法使用企业版', icon: 'none', duration: 2500 }) + } + }).catch(() => { + wx.showToast({ title: '无法获取配置,请稍后重试', icon: 'none' }) + }) + }, + + onShareAppMessage() { + const { getSharePathByScope } = require('../../utils/share') + return { + title: 'AI人脸性格分析 - 看看你的面相透露了什么性格密码', + path: getSharePathByScope('/pages/index/index') + } + }, + + onShareTimeline() { + const { buildShareQuery } = require('../../utils/share') + return { + title: 'AI人脸性格分析 - 看看你的面相透露了什么性格密码', + query: buildShareQuery() + } + } +}) diff --git a/miniprogram/pages/index/index.json b/miniprogram/pages/index/index.json new file mode 100644 index 0000000..3d61cfc --- /dev/null +++ b/miniprogram/pages/index/index.json @@ -0,0 +1,6 @@ +{ + "navigationStyle": "custom", + "usingComponents": { + "custom-tab-bar": "/custom-tab-bar/index" + } +} diff --git a/miniprogram/pages/index/index.wxml b/miniprogram/pages/index/index.wxml new file mode 100644 index 0000000..58dfd1f --- /dev/null +++ b/miniprogram/pages/index/index.wxml @@ -0,0 +1,61 @@ + + + + + + + 🏢 + 企业版 + + {{siteTitle || '神仙团队AI性格测试'}} + + + + + + + + + + + + + + 面相分析 + 骨相分析 + 性格测评 + + + + + 测试流程 + + + 1 + STEP1 + 拍摄照片 + + + + 2 + STEP2 + {{aiAnalysisText || '智能分析'}} + + + + 3 + STEP3 + 生成报告 + + + + + + {{startButtonText || '开始面相测试'}} + + + 人工智能生成 + + + + diff --git a/miniprogram/pages/index/index.wxss b/miniprogram/pages/index/index.wxss new file mode 100644 index 0000000..76bb48e --- /dev/null +++ b/miniprogram/pages/index/index.wxss @@ -0,0 +1,278 @@ +/* pages/index/index.wxss - 个人版首页,样式与企业版一致 */ +.container { + min-height: 100vh; + width: 100vw; + overflow-x: hidden; + overflow-y: auto; + background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 50%, #ffffff 100%); + position: relative; + display: flex; + flex-direction: column; + box-sizing: border-box; + padding-bottom: calc(100rpx + env(safe-area-inset-bottom) + 40rpx); +} + +/* 背景装饰圆形(与企业版一致) */ +.bg-decoration { + position: absolute; + width: 600rpx; + height: 600rpx; + border-radius: 50%; + pointer-events: none; + z-index: 0; +} + +.bg-top-right { + top: -200rpx; + right: -200rpx; + background: radial-gradient(circle, rgba(139, 92, 246, 0.08) 0%, transparent 70%); +} + +.bg-bottom-left { + bottom: 0; + left: 0; + background: radial-gradient(circle, rgba(99, 102, 241, 0.06) 0%, transparent 70%); +} + +/* 自定义导航栏 */ +.custom-navbar { + position: fixed; + top: 0; + left: 0; + right: 0; + background: #ffffff; + z-index: 10000; + width: 100%; +} + +.navbar-content { + height: 88rpx; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 40rpx; + position: relative; + background: #ffffff; + min-height: 88rpx; +} + +.navbar-title { + position: absolute; + left: 50%; + transform: translateX(-50%); + font-size: 36rpx; + font-weight: 700; + color: #e63946; + text-align: center; + flex: 1; +} + +.navbar-placeholder { + width: 140rpx; + flex-shrink: 0; +} + +/* 与企业版切换按钮同款样式(红主色) */ +.switch-enterprise-btn { + display: flex; + align-items: center; + gap: 8rpx; + padding: 12rpx 20rpx; + background: rgba(230, 57, 70, 0.1); + border-radius: 30rpx; + border: 1rpx solid rgba(230, 57, 70, 0.2); + flex-shrink: 0; + z-index: 10; +} + +.enterprise-icon { + font-size: 28rpx; +} + +.enterprise-text { + font-size: 24rpx; + color: #e63946; + font-weight: 600; +} + +.top-image-section { + width: 100%; + padding: 15rpx 40rpx 20rpx; + flex-shrink: 0; + box-sizing: border-box; + position: relative; + z-index: 1; + margin-top: 0; +} + +.image-container { + position: relative; + width: 100%; +} + +.image-wrapper { + position: relative; + width: 100%; + padding-top: 100%; + border-radius: 50%; + overflow: hidden; + background: #ffffff; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.12); +} + +.main-image { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 85%; + height: 85%; + display: block; +} + +.float-tag { + position: absolute; + padding: 12rpx 24rpx; + border-radius: 30rpx; + font-size: 24rpx; + font-weight: 600; + color: #e63946; + background: rgba(255, 255, 255, 0.95); + box-shadow: 0 4rpx 16rpx rgba(230, 57, 70, 0.2); + white-space: nowrap; + z-index: 10; +} + +.tag-1 { + top: 15%; + right: 10rpx; +} + +.tag-2 { + bottom: 25%; + left: 0; +} + +.tag-3 { + bottom: 25%; + right: 0; +} + +.process-section { + padding: 15rpx 40rpx 20rpx; + flex-shrink: 0; + position: relative; + z-index: 1; +} + +.section-title { + font-size: 32rpx; + font-weight: 700; + color: #e63946; + text-align: center; + margin-bottom: 25rpx; +} + +.process-steps { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 10rpx; +} + +.step-item { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; +} + +.step-circle { + width: 70rpx; + height: 70rpx; + border-radius: 50%; + background: #b8b9bc; + display: flex; + align-items: center; + justify-content: center; + font-size: 36rpx; + font-weight: 700; + color: #fff; + margin-bottom: 12rpx; +} + +.step-circle.active { + background: #e63946; +} + +.step-label { + font-size: 20rpx; + color: #666; + margin-bottom: 4rpx; +} + +.step-text { + font-size: 24rpx; + color: #333; + font-weight: 600; +} + +.step-line { + width: 50rpx; + height: 4rpx; + background: #e0e0e0; + margin: 0 6rpx 50rpx; +} + +.start-button { + margin: 20rpx 40rpx 0; + background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%); + border-radius: 50rpx; + padding: 24rpx; + text-align: center; + box-shadow: 0 8rpx 30rpx rgba(230, 57, 70, 0.3); + position: relative; + z-index: 1; +} + +.button-text { + font-size: 30rpx; + color: #fff; + font-weight: 700; +} + +.upload-button { + margin: 0 40rpx 16rpx; + border: 2rpx solid #8b5cf6; + border-radius: 50rpx; + padding: 18rpx; + text-align: center; + background: #fff; + flex-shrink: 0; +} + +.upload-text { + font-size: 26rpx; + color: #8b5cf6; + font-weight: 600; +} + +.ai-generated-tip { + text-align: center; + font-size: 24rpx; + color: #9CA3AF; + margin-top: 24rpx; + margin-bottom: 16rpx; +} + +/* 确保底部导航显示 */ +custom-tab-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 9999; +} diff --git a/miniprogram/pages/index/result.js b/miniprogram/pages/index/result.js new file mode 100644 index 0000000..419239c --- /dev/null +++ b/miniprogram/pages/index/result.js @@ -0,0 +1,539 @@ +// pages/index/result.js - AI分析结果页(按旧版模板重构) +const app = getApp() +const payment = require('../../utils/payment') +const { hasPhone, bindPhoneByCode, ensureProfileCompleteAndRedirect } = require('../../utils/phoneAuth.js') + +Page({ + data: { + isAnalyzing: true, + showResult: false, + hasError: false, + errorMessage: '', + noFaceError: false, + noFaceMessage: '', + progress: 0, + analyzingTip: '正在识别面部特征...', + activeTab: 'face', // 'face' | 'bone' + // 报告付费信息 + payInfo: { + requiresPayment: false, // 是否需要付费才解锁完整报告 + isPaid: false, // 当前这次是否已解锁 + amountYuan: 0 // 人脸报告价格(元) + }, + result: { + mbti: '', + title: '', + summary: '', + pdp: '', + pdpAux: '', + pdpEmoji: '', + disc: '', + discAux: '', + traits: [], + faceAnalysisText: '', + boneAnalysisText: '', + careers: [], + relationship: '', + gallupTop3: [], + // 完整面相/骨相(object 才用,字符串走 *Text 字段) + faceAnalysis: null, + boneAnalysis: null, + // 企业版:职业画像、HR视角、老板视角、简历亮点 + portrait: null, + hrView: null, + bossView: null, + resumeHighlights: '' + }, + // 当前这次AI分析对应的测试记录ID(mbti_test_results.id) + testResultId: null, + // 支付后是否已经触发过一次“刷新完整报告”,避免重复刷新 + hasReloadedAfterPay: false, + // 是否已在本地拥有手机号(决定是否还需要弹出微信手机号授权) + hasPhone: false, + analyzingTitle: '正在分析中', + reportTitle: '分析报告', + aiAnalysisText: '智能分析' + }, + + onLoad(options) { + const id = options && options.id + const type = options && options.type + + // 加载文案配置(分析中提示、报告标题等) + const tc = app.globalData.textConfig + if (tc) { + this.setData({ + analyzingTitle: tc.analyzingTitle || '正在分析中', + reportTitle: tc.reportTitle || '分析报告', + aiAnalysisText: tc.aiAnalysisText || '智能分析' + }) + if (tc.reportTitle) wx.setNavigationBarTitle({ title: tc.reportTitle }) + } else { + app.getRuntimeConfig().then((cfg) => { + if (cfg && cfg.textConfig) { + app.globalData.textConfig = cfg.textConfig + const t = cfg.textConfig + this.setData({ + analyzingTitle: t.analyzingTitle || '正在分析中', + reportTitle: t.reportTitle || '分析报告', + aiAnalysisText: t.aiAnalysisText || '智能分析' + }) + if (t.reportTitle) wx.setNavigationBarTitle({ title: t.reportTitle }) + } + }).catch(() => {}) + } + + // 从历史记录进入:根据ID从后端读取数据库中的结果 + if (id && type === 'ai') { + const token = app.globalData.token || wx.getStorageSync('token') + const apiBase = app.globalData.apiBase + if (!token || !apiBase) { + wx.showToast({ title: '未登录,无法读取历史记录', icon: 'none' }) + setTimeout(() => wx.navigateBack(), 1500) + return + } + + wx.showLoading({ title: '加载历史记录...' }) + wx.request({ + url: `${apiBase}/api/test/detail`, + method: 'GET', + header: { + 'Authorization': `Bearer ${token}` + }, + data: { id }, + success: (res) => { + wx.hideLoading() + if (res.statusCode === 200 && res.data && res.data.data) { + const payload = res.data.data + const apiData = payload.data || payload + // 历史详情场景下,记录当前测试记录ID + this.setData({ testResultId: id }) + this.processResult(apiData) + // 从历史进入:金额与是否需付款以 test_results 的 paidAmount/needPaymentToUnlock 为准 + this.initPayInfoFromRuntime( + !!payload.requiresPayment, + !!payload.isPaid, + payload + ) + } else { + wx.showToast({ title: res.data?.message || '加载失败', icon: 'none' }) + setTimeout(() => wx.navigateBack(), 1500) + } + }, + fail: () => { + wx.hideLoading() + wx.showToast({ title: '网络错误,加载失败', icon: 'none' }) + setTimeout(() => wx.navigateBack(), 1500) + } + }) + return + } + + // 正常从拍照流程进入:调用 /api/analyze + this.startAnalysis() + }, + + onShow() { + if (!ensureProfileCompleteAndRedirect()) return + this.setData({ hasPhone: hasPhone() }) + const tc = app.globalData.textConfig + if (tc) { + this.setData({ + analyzingTitle: tc.analyzingTitle || '正在分析中', + reportTitle: tc.reportTitle || '分析报告', + aiAnalysisText: tc.aiAnalysisText || '智能分析' + }) + } + }, + + // 面相/骨相 Tab 切换 + switchAnalysisTab(e) { + this.setData({ activeTab: e.currentTarget.dataset.tab }) + }, + + // 调用后端AI分析API(aiPhotos 应为上传后的 URL 数组,由拍照页上传后写入) + startAnalysis() { + let photos = wx.getStorageSync('aiPhotos') || [] + if (!Array.isArray(photos)) photos = [] + const isUrls = photos.length > 0 && photos.every(p => typeof p === 'string' && (p.startsWith('http://') || p.startsWith('https://'))) + if (!isUrls || photos.length === 0) { + this.setData({ progress: 100 }) + setTimeout(() => { + wx.showToast({ title: '请先完成拍照并上传', icon: 'none' }) + wx.navigateBack({ delta: 1 }) + }, 300) + return + } + const tips = [ + '正在识别面部特征...', + '分析眉眼特征...', + '结合《冰鉴》分析骨形...', + '匹配MBTI/PDP/DISC...', + '生成综合报告...' + ] + let progress = 0 + let tipIndex = 0 + + // 进度动画 + const timer = setInterval(() => { + progress += 3 + if (progress > 95) progress = 95 + if (progress > (tipIndex + 1) * 18 && tipIndex < tips.length - 1) tipIndex++ + this.setData({ progress: Math.floor(progress), analyzingTip: tips[tipIndex] }) + }, 200) + + // 调用后端API:appScope='enterprise' 时才传 enterpriseId,个人版不传 + const userInfo = app.globalData.userInfo || wx.getStorageSync('userInfo') || {} + const scope = (app.globalData && app.globalData.appScope) || 'personal' + const enterpriseId = scope === 'enterprise' + ? (app.globalData.enterpriseIdFromScene || userInfo.enterpriseId || null) + : null + wx.request({ + url: `${app.globalData.apiBase}/api/analyze`, + method: 'POST', + header: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${wx.getStorageSync('token') || ''}` + }, + data: { + photoUrls: photos, + userId: app.globalData.openId || '', + ...(enterpriseId ? { enterpriseId: Number(enterpriseId) } : {}) + }, + success: (res) => { + clearInterval(timer) + + if (res.statusCode === 200 && res.data) { + const bodyCode = res.data.code + // 后端 error() 始终返回 HTTP 200,业务错误码放在 data.code 中 + if (bodyCode === 422) { + // AI 未检测到人脸 + const msg = res.data.message || '图片中未检测到人脸,请确保拍摄时面部清晰可见' + this.setData({ progress: 100 }) + setTimeout(() => this.showNoFaceError(msg), 300) + } else if (bodyCode === 200) { + const apiData = res.data.data || res.data + this.setData({ progress: 100, analyzingTip: '分析完成!' }) + setTimeout(() => { + this.processResult(apiData) + }, 500) + } else { + console.error('API返回错误:', res) + this.setData({ progress: 100 }) + const msg = (res.data && (res.data.message || res.data.msg)) || '分析失败,请稍后重试' + setTimeout(() => this.showAnalyzeError(msg), 300) + } + } else { + console.error('API返回错误:', res) + this.setData({ progress: 100 }) + setTimeout(() => this.showAnalyzeError('分析失败,请稍后重试'), 300) + } + }, + fail: (err) => { + clearInterval(timer) + console.error('API调用失败:', err) + this.setData({ progress: 100 }) + setTimeout(() => this.showAnalyzeError('网络异常,请检查网络后重试'), 300) + } + }) + }, + + // 处理API返回结果 + processResult(apiData) { + const result = { + mbti: apiData.mbti?.type || '', + title: apiData.mbti?.title || '', + summary: apiData.personalitySummary || apiData.overview || '', + pdp: apiData.pdp?.primary || '', + pdpAux: apiData.pdp?.secondary || '', + pdpEmoji: this.getPDPEmoji(apiData.pdp?.primary), + disc: apiData.disc?.primary || '', + discAux: apiData.disc?.secondary || '', + traits: Array.isArray(apiData.advantages) ? apiData.advantages : [], + faceAnalysisText: typeof apiData.faceAnalysis === 'string' ? apiData.faceAnalysis : '', + boneAnalysisText: typeof apiData.boneAnalysis === 'string' ? apiData.boneAnalysis : '', + careers: Array.isArray(apiData.careers) ? apiData.careers : [], + relationship: apiData.relationship || '', + gallupTop3: Array.isArray(apiData.gallupTop3) ? apiData.gallupTop3 : [], + faceAnalysis: null, + boneAnalysis: null, + portrait: apiData.portrait || null, + hrView: apiData.hrView || null, + bossView: apiData.bossView || null, + resumeHighlights: apiData.resumeHighlights || '', + timestamp: Date.now() + } + + wx.setStorageSync('aiResult', result) + if (app.globalData) app.globalData.aiResult = result + // 测试记录已由 /api/analyze 顺带写入,无需再调 /api/test/submit + + const updates = { + isAnalyzing: false, + showResult: true, + hasError: false, + result + } + + // 记录本次测试记录ID(由 /api/analyze 返回) + if (apiData && apiData._testResultId) { + updates.testResultId = apiData._testResultId + } + + this.setData(updates) + + // 优先使用后端 /api/analyze 返回的价格信息,避免二次请求 + if (apiData._payment) { + const p = apiData._payment || {} + this.setData({ + payInfo: { + requiresPayment: !!p.requiresPayment, + isPaid: false, + amountYuan: typeof p.amountYuan === 'number' + ? p.amountYuan + : (p.amountFen ? (p.amountFen / 100) : 0) + } + }) + } + // 无 _payment 时(如从历史详情进入)不在这里调 initPayInfoFromRuntime,避免异步 getRuntimeConfig 后覆盖详情接口返回的 needPaymentToUnlock;由调用方用 detail 的 payload 单独设置 payInfo + }, + + + // PDP类型对应emoji + getPDPEmoji(type) { + const map = { + '老虎': '🐅', '孔雀': '🦚', '无尾熊': '🐨', '考拉': '🐨', + '猫头鹰': '🦉', '变色龙': '🦎' + } + return map[type] || '🐅' + }, + + /** + * 初始化付费信息:结合运行配置 + 可选的数据库记录状态 + * @param {boolean|null} recordRequires 是否从记录中读到需要付费(可为空) + * @param {boolean} recordIsPaid 记录是否已付费 + */ + initPayInfoFromRuntime(recordRequires = null, recordIsPaid = false, detailPayload = null) { + // 从历史/详情进入:优先用 test_results 的 paidAmount,需付款但金额为0 则直接可查看 + if (detailPayload && (detailPayload.paidAmount != null || detailPayload.amountYuan != null || detailPayload.needPaymentToUnlock != null)) { + const paidAmount = Number(detailPayload.paidAmount ?? 0) + const amountYuan = detailPayload.amountYuan != null ? Number(detailPayload.amountYuan) : (paidAmount > 0 ? paidAmount / 100 : 0) + const needPay = detailPayload.needPaymentToUnlock === true || (!!detailPayload.requiresPayment && !detailPayload.isPaid && paidAmount > 0) + this.setData({ + payInfo: { + requiresPayment: needPay, + isPaid: !!detailPayload.isPaid, + amountYuan: needPay ? amountYuan : 0 + } + }) + return + } + app.getRuntimeConfig() + .then((cfg) => { + const pricing = cfg.pricing || {} + const reportRequires = cfg.reportRequiresPayment || {} + const facePriceRaw = pricing.face + const facePrice = typeof facePriceRaw === 'number' + ? facePriceRaw + : Number(facePriceRaw || 0) + + const requiresByConfig = !!(reportRequires && reportRequires.face) + const requiresPayment = + typeof recordRequires === 'boolean' ? recordRequires : requiresByConfig + // 系统设置需付款但金额为0 则直接可查看,不展示付费墙 + const needPay = requiresPayment && facePrice > 0 + const amountYuan = facePrice > 0 ? facePrice : 0 + + this.setData({ + payInfo: { + requiresPayment: needPay, + isPaid: !!recordIsPaid, + amountYuan + } + }) + }) + .catch(() => { + this.setData({ + payInfo: { + requiresPayment: false, + isPaid: !!recordIsPaid, + amountYuan: 0 + } + }) + }) + }, + + // 解锁完整报告:发起人脸测试付费 + unlockFullReport() { + const { payInfo, testResultId, hasReloadedAfterPay } = this.data + if (!payInfo.requiresPayment || payInfo.isPaid) { + return + } + + app.ensureLogin().then((logged) => { + if (!logged) { + wx.showToast({ title: '请先登录后再解锁', icon: 'none' }) + return + } + payment.purchaseFaceTest({ + testResultId, + success: () => { + wx.showToast({ title: '已解锁完整报告', icon: 'success' }) + + // 本地先标记已付费,避免按钮仍然提示“需要解锁” + this.setData({ + 'payInfo.isPaid': true + }) + + // 避免重复触发刷新:只在还没刷新的情况下,延迟 0.5s 拉一次详情 + if (testResultId && !hasReloadedAfterPay) { + this.setData({ hasReloadedAfterPay: true }) + setTimeout(() => { + this.reloadFullDetail(testResultId) + }, 500) + } + }, + fail: () => { + // 支付失败或取消,这里不做额外处理 + } + }) + }) + }, + + // AI结果页付费按钮:就地触发微信手机号授权,然后调用 unlockFullReport + onGetPhoneNumberForFacePay(e) { + if (!ensureProfileCompleteAndRedirect()) return + const { code, errMsg } = e.detail || {} + if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) { + if (!hasPhone()) { + wx.showToast({ title: '需要授权手机号才能继续', icon: 'none' }) + return + } + this.unlockFullReport() + return + } + if (!code) { + if (hasPhone()) { + this.unlockFullReport() + } else { + wx.showToast({ title: '获取手机号失败', icon: 'none' }) + } + return + } + bindPhoneByCode(code) + .then(() => { + this.setData({ hasPhone: true }) + this.unlockFullReport() + }) + .catch(() => { + // 保持在当前页,等待用户重新点击 + }) + }, + + // 解锁成功后,根据测试记录ID重新拉取完整详情 + reloadFullDetail(id) { + if (!id) return + + const app = getApp() + const apiBase = app.globalData?.apiBase || '' + + wx.showLoading({ title: '加载完整报告...' }) + wx.request({ + url: `${apiBase}/api/test/detail`, + method: 'GET', + header: { + 'Authorization': app.globalData?.token ? `Bearer ${app.globalData.token}` : '' + }, + data: { id }, + success: (res) => { + if (res.statusCode === 200 && res.data && res.data.data) { + const payload = res.data.data + const apiData = payload.data || payload + + // 详情接口在后台会根据 isPaid 返回完整结构,这里直接复用已有处理逻辑 + this.processResult(apiData) + + // 同步数据库中的付费状态与金额(test_results.paidAmount) + const recordRequires = + typeof payload.requiresPayment === 'boolean' || typeof payload.requiresPayment === 'number' + ? !!payload.requiresPayment + : null + const recordIsPaid = !!payload.isPaid + this.initPayInfoFromRuntime(recordRequires, recordIsPaid, payload) + } else { + console.error('刷新完整报告失败', res) + } + }, + fail: (err) => { + console.error('请求完整报告失败', err) + }, + complete: () => { + wx.hideLoading() + } + }) + }, + + // 无人脸错误:显示提示并提供重新拍摄入口 + showNoFaceError(message) { + this.setData({ + isAnalyzing: false, + showResult: false, + noFaceError: true, + noFaceMessage: message || '图片中未检测到人脸,请重新拍摄清晰的正面照片' + }) + }, + + // 分析失败:不使用任何本地模拟数据 + showAnalyzeError(message) { + this.setData({ + isAnalyzing: false, + showResult: true, + hasError: true, + errorMessage: message || '分析失败,请稍后重试' + }) + }, + + // 跳转到详情性格测试选择页(MBTI / PDP / DISC 三选一) + goToMBTI() { + wx.navigateTo({ url: '/pages/test-select/index' }) + }, + + shareResult() { + wx.showShareMenu({ withShareTicket: true, menus: ['shareAppMessage', 'shareTimeline'] }) + }, + + retake() { + // camera 是 tabBar 页面,必须用 switchTab + wx.switchTab({ url: '/pages/index/camera' }) + }, + + goHome() { + // 根据当前 scope 跳对应首页 + const scope = (getApp().globalData && getApp().globalData.appScope) || 'personal' + if (scope === 'enterprise') { + wx.navigateTo({ url: '/pages/enterprise/index' }) + } else { + wx.switchTab({ url: '/pages/index/index' }) + } + }, + + onShareAppMessage() { + const r = this.data.result + const t = this.data.aiAnalysisText || '智能分析' + const { getSharePathByScope } = require('../../utils/share') + return { + title: `${t}我是${r?.mbti} ${r?.pdpEmoji}${r?.pdp}型,来测测你的!`, + path: getSharePathByScope('/pages/index/index') + } + }, + + onShareTimeline() { + const r = this.data.result + const t = this.data.aiAnalysisText || '智能分析' + const { buildShareQuery } = require('../../utils/share') + return { + title: `${t}我是${r?.mbti} ${r?.pdpEmoji}${r?.pdp}型,来测测你的!`, + query: buildShareQuery() + } + } +}) diff --git a/miniprogram/pages/index/result.json b/miniprogram/pages/index/result.json new file mode 100644 index 0000000..2c985f3 --- /dev/null +++ b/miniprogram/pages/index/result.json @@ -0,0 +1,6 @@ +{ + "navigationBarTitleText": "分析报告", + "navigationBarBackgroundColor": "#FFFFFF", + "navigationBarTextStyle": "black", + "usingComponents": {} +} diff --git a/miniprogram/pages/index/result.wxml b/miniprogram/pages/index/result.wxml new file mode 100644 index 0000000..a81af27 --- /dev/null +++ b/miniprogram/pages/index/result.wxml @@ -0,0 +1,304 @@ + + + + + + + + + {{analyzingTitle || '正在分析中'}} + {{analyzingTip}} + + + + 这个过程可能需要30秒到1分钟,请耐心等待... + + + + + + + + 🚫 + + 未检测到人脸 + {{noFaceMessage}} + + 拍摄建议: + • 确保面部完整出现在画面中 + • 保持光线充足,避免逆光 + • 正对镜头,不要遮挡面部 + • 保持适当距离,不要过近或过远 + + + 重新拍摄 + + + + + + + + + + {{hasError ? '分析出现异常' : '分析完成,以下是您的个性化报告'}} + + + + + 错误信息 + {{errorMessage}} + + 重新分析 + + + + + + 性格类型分析 + + + + {{result.mbti}} + {{result.title}} + {{result.summary}} + + + + + + PDP主性格 + {{result.pdp || '--'}} + 辅助: {{result.pdpAux}} + + + DISC类型 + {{result.disc || '--'}}型 + 辅助: {{result.discAux}} + + + + + + 主要优势 + + + + {{item}} + + + + + + 下一步:详细性格测试 → + + + + + + 性格概述 + {{result.summary}} + + + + + 盖洛普前三大优势 + + + {{index + 1}} + {{item}} + + + + + + + + 完整版性格深度解析 + 解锁后将展示完整报告内容 + + + + + 一次性解锁本次{{reportTitle || '分析报告'}},永久保存在「历史记录」中 + + + + + + + + + + + 面部特征分析 + + + {{item.label}}: + {{item.description}} + + + + + + + 面相分析 + {{result.faceAnalysisText}} + + + + + + + 骨相分析(《冰鉴》八骨) + + + 五行形相: + {{result.boneAnalysis.boneFormSummary.elementType}} + + + 骨肉关系: + {{result.boneAnalysis.boneFormSummary.boneFleshRelation}} + + + + + + + 骨相分析(《冰鉴》八骨) + {{result.boneAnalysisText}} + + + + + 人际关系与团队合作 + {{result.relationship}} + + + + + 简历亮点 + {{result.resumeHighlights}} + + + + + 职业画像 + + 核心优势 + + {{item}} + + + + 潜在风险 + + {{item}} + + + + 工作风格 + {{result.portrait.workStyle}} + + + + + + HR视角 + + 岗位推荐 + + 最适合 + + {{item}} + + + + 不适合 + + {{item}} + + + + + 成长周期 + + + 入职适应 + {{result.hrView.lifecycle.onboarding}} + + + 试用期 + {{result.hrView.lifecycle.probation}} + + + 成长期 + {{result.hrView.lifecycle.growth}} + + + 留人因素 + {{result.hrView.lifecycle.retention}} + + + + + 绩效潜力 + 潜力:{{result.hrView.performance.potential}} + + 驱动因子: + {{result.hrView.performance.drivers.join('、')}} + + + + 团队适配 + {{result.hrView.teamFit.bestTeam || ''}} + 管理建议:{{result.hrView.teamFit.manageAdvice}} + + + + + + 老板视角 + {{result.bossView.headline}} + + + {{item.label}} + {{item.value}} + + + 用人成本预判:{{result.bossView.costInsight}} + + + + + + + + + 重新分析 + + + 返回首页 + + + + 人工智能生成 + + diff --git a/miniprogram/pages/index/result.wxss b/miniprogram/pages/index/result.wxss new file mode 100644 index 0000000..21131fc --- /dev/null +++ b/miniprogram/pages/index/result.wxss @@ -0,0 +1,984 @@ +/* pages/index/result.wxss - AI分析结果(按旧版模板重构) */ +.container { + min-height: 100vh; + background-color: #f5f5f5; + padding: 0 24rpx 60rpx; +} + +/* ========== 分析中模态框 ========== */ +.analyzing-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} + +.analyzing-content { + background-color: #fff; + border-radius: 24rpx; + padding: 80rpx 60rpx; + width: 560rpx; + display: flex; + flex-direction: column; + align-items: center; + box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.15); +} + +.analyzing-icon { + width: 120rpx; + height: 120rpx; + margin-bottom: 40rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.spinner { + width: 120rpx; + height: 120rpx; + border: 8rpx solid #e0e0e0; + border-top-color: #e63946; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.analyzing-title { + font-size: 36rpx; + font-weight: 600; + color: #333; + margin-bottom: 16rpx; +} + +.analyzing-desc { + font-size: 28rpx; + color: #e63946; + margin-bottom: 24rpx; + text-align: center; + font-weight: 500; +} + +.analyzing-bar { + width: 400rpx; + height: 12rpx; + background: #e5e5e5; + border-radius: 6rpx; + overflow: hidden; + margin-bottom: 24rpx; +} + +.analyzing-bar-fill { + height: 100%; + background: linear-gradient(90deg, #e63946, #ff6b9d); + border-radius: 6rpx; + transition: width 0.3s ease; +} + +.analyzing-hint { + font-size: 24rpx; + color: #999; + text-align: center; + line-height: 1.6; +} + +/* ========== 状态横幅 ========== */ +.status-banner { + background: linear-gradient(135deg, #e8f5e9 0%, #f1f8e9 100%); + padding: 24rpx; + border-radius: 12rpx; + display: flex; + align-items: center; + margin: 24rpx 0; +} + +.status-dot { + width: 12rpx; + height: 12rpx; + background-color: #4caf50; + border-radius: 50%; + margin-right: 16rpx; +} + +.status-text { + font-size: 26rpx; + color: #2e7d32; +} + +.error-banner { + background: linear-gradient(135deg, #ffebee 0%, #ffcdd2 100%); +} + +.error-dot { + background-color: #f44336; +} + +.error-banner .status-text { + color: #c62828; + font-weight: bold; +} + +.error-content { + background-color: #fff; + border-radius: 16rpx; + padding: 32rpx; + margin-bottom: 24rpx; + box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04); + border-left: 4rpx solid #f44336; +} + +.error-title { + font-size: 28rpx; + color: #c62828; + font-weight: bold; + display: block; + margin-bottom: 16rpx; +} + +.error-message { + font-size: 24rpx; + color: #666; + line-height: 1.8; + display: block; + white-space: pre-wrap; + word-break: break-all; + background-color: #f5f5f5; + padding: 16rpx; + border-radius: 8rpx; + margin-bottom: 24rpx; +} + +.retry-btn { + background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%); + padding: 20rpx; + border-radius: 12rpx; + text-align: center; +} + +.retry-text { + font-size: 28rpx; + color: #fff; + font-weight: 500; +} + +/* ========== 通用卡片 ========== */ +.card { + background-color: #fff; + border-radius: 16rpx; + padding: 32rpx; + margin-bottom: 24rpx; + box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04); + word-wrap: break-word; +} + +.card-title { + font-size: 32rpx; + font-weight: 600; + color: #333; + display: block; + margin-bottom: 24rpx; +} + +.card-text { + font-size: 28rpx; + color: #666; + line-height: 1.8; + display: block; +} + +/* ========== 付费解锁毛玻璃区域 ========== */ +.paywall-card { + position: relative; + overflow: hidden; +} + +.paywall-content { + position: relative; +} + +.paywall-blur { + padding: 32rpx 24rpx 140rpx; + border-radius: 24rpx; + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(18rpx); +} + +.paywall-mask { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + border-radius: 24rpx; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.1), rgba(245, 245, 245, 0.98)); + pointer-events: none; +} + +.paywall-fake-title { + display: block; + font-size: 30rpx; + font-weight: 600; + color: #444; + margin-bottom: 16rpx; +} + +.paywall-fake-line { + display: block; + font-size: 26rpx; + color: #888; + line-height: 1.8; +} + +.paywall-btn { + position: absolute; + left: 5%; + right: 5%; + width: 90%; + bottom: 56rpx; + padding: 20rpx 0; + border-radius: 999rpx; + background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%); + box-shadow: 0 8rpx 24rpx rgba(230, 57, 70, 0.35); + display: flex; + flex-direction: row; + align-items: baseline; + justify-content: center; + gap: 12rpx; +} + +.paywall-btn-main { + font-size: 30rpx; + color: #fff; + font-weight: 600; +} + +.paywall-btn-price { + font-size: 24rpx; + color: #ffe5f0; +} + +.paywall-tip { + display: block; + margin-top: 8rpx; + font-size: 22rpx; + color: #999; + text-align: center; +} + +/* ========== 性格类型卡片 ========== */ +.personality-card { + text-align: center; +} + +.personality-type { + margin-bottom: 32rpx; +} + +.type-code { + font-size: 72rpx; + font-weight: 700; + color: #e63946; + display: block; + margin-bottom: 8rpx; +} + +.type-name { + font-size: 28rpx; + color: #666; + display: block; + margin-bottom: 16rpx; +} + +.type-desc { + font-size: 26rpx; + color: #888; + line-height: 1.6; + display: block; + text-align: center; + padding: 0 20rpx; +} + +.type-details { + display: flex; + gap: 24rpx; + margin-bottom: 24rpx; +} + +.type-box { + flex: 1; + padding: 24rpx; + border-radius: 12rpx; + text-align: center; +} + +.pdp-box { + background: linear-gradient(135deg, #faf5fc 0%, #f3e5f5 100%); +} + +.disc-box { + background: linear-gradient(135deg, #f5f9fd 0%, #e3f2fd 100%); +} + +.type-label { + font-size: 24rpx; + color: #666; + display: block; + margin-bottom: 8rpx; +} + +.type-value { + font-size: 30rpx; + font-weight: 600; + display: block; + margin-bottom: 4rpx; +} + +.pdp-box .type-value { color: #7b1fa2; } +.disc-box .type-value { color: #1976d2; } + +.type-sub { + font-size: 22rpx; + color: #999; + display: block; +} + +/* 主要优势 */ +.advantages-box { + background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%); + padding: 24rpx; + border-radius: 12rpx; + margin-bottom: 24rpx; +} + +.advantages-label { + font-size: 26rpx; + color: #666; + display: block; + text-align: center; + margin-bottom: 16rpx; +} + +.advantages-list { + display: flex; + justify-content: space-around; + flex-wrap: wrap; + gap: 16rpx; +} + +.advantage-item { + display: flex; + flex-direction: column; + align-items: center; +} + +.advantage-dot { + width: 12rpx; + height: 12rpx; + background-color: #4caf50; + border-radius: 50%; + margin-bottom: 8rpx; +} + +.advantage-text { + font-size: 24rpx; + font-weight: 500; + color: #333; +} + +.next-btn { + background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%); + padding: 24rpx; + border-radius: 48rpx; + box-shadow: 0 8rpx 16rpx rgba(230, 57, 70, 0.3); +} + +.next-btn-text { + font-size: 28rpx; + font-weight: 600; + color: #fff; + display: block; + text-align: center; +} + +/* ========== Tab切换 ========== */ +.tabs { + display: flex; + border-bottom: 2rpx solid #f0f0f0; + margin-bottom: 24rpx; + background-color: #fff; + border-radius: 16rpx 16rpx 0 0; +} + +.tab { + flex: 1; + padding: 24rpx; + text-align: center; + position: relative; +} + +.tab.active::after { + content: ""; + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 60rpx; + height: 4rpx; + background-color: #e63946; + border-radius: 2rpx; +} + +.tab-text { + font-size: 28rpx; + color: #999; +} + +.tab-text.active { + color: #e63946; + font-weight: 600; +} + +/* ========== 面相分析 ========== */ +.feature-list { + display: flex; + flex-direction: column; + gap: 20rpx; +} + +.feature-item { + display: flex; + font-size: 26rpx; + line-height: 1.8; +} + +.feature-label { + font-weight: 600; + color: #333; + flex-shrink: 0; +} + +.feature-desc { + color: #666; + flex: 1; +} + +/* 职业建议 */ +.career-list { + display: flex; + flex-direction: column; + gap: 24rpx; +} + +.career-item { + display: flex; + flex-direction: column; + gap: 8rpx; +} + +.career-header { + display: flex; + align-items: center; +} + +.career-dot { + width: 12rpx; + height: 12rpx; + background-color: #e63946; + border-radius: 50%; + margin-right: 16rpx; + flex-shrink: 0; +} + +.career-name { + font-size: 28rpx; + font-weight: 600; + color: #333; +} + +.career-reason { + font-size: 24rpx; + color: #666; + line-height: 1.6; + padding-left: 28rpx; +} + +/* 人际关系 */ +.interpersonal-content { + display: flex; + flex-direction: column; + gap: 20rpx; +} + +.interpersonal-item { + display: flex; + font-size: 26rpx; + line-height: 1.8; +} + +.interpersonal-label { + font-weight: 600; + color: #333; + flex-shrink: 0; +} + +.interpersonal-text { + color: #666; + flex: 1; +} + +/* 潜能 */ +.potential-list { + display: flex; + flex-direction: column; + gap: 20rpx; +} + +.potential-item { + display: flex; + flex-direction: column; + gap: 8rpx; +} + +.potential-name { + font-size: 28rpx; + font-weight: 600; + color: #333; +} + +.potential-desc { + font-size: 24rpx; + color: #666; + line-height: 1.6; + padding-left: 20rpx; +} + +/* ========== 骨相分析 ========== */ +.bone-content { + display: flex; + flex-direction: column; + gap: 20rpx; +} + +.bone-item { + display: flex; + font-size: 26rpx; + line-height: 1.8; +} + +.bone-label { + font-weight: 600; + color: #333; + flex-shrink: 0; + min-width: 120rpx; +} + +.bone-text { + color: #666; + flex: 1; +} + +/* 人生阶段 */ +.life-stage-list { + display: flex; + flex-direction: column; + gap: 24rpx; +} + +.life-stage-item { + background: linear-gradient(135deg, #fff9e6 0%, #fff3cd 100%); + padding: 20rpx; + border-radius: 12rpx; + border-left: 4rpx solid #ff9800; +} + +.stage-label { + font-size: 28rpx; + font-weight: 600; + color: #e65100; + display: block; + margin-bottom: 8rpx; +} + +.stage-desc { + font-size: 26rpx; + color: #666; + line-height: 1.8; + display: block; +} + +/* 骨相总评 */ +.bone-summary-content { + display: flex; + flex-direction: column; + gap: 24rpx; +} + +.summary-text { + font-size: 28rpx; + color: #333; + line-height: 1.8; + display: block; +} + +.suggestions-title { + font-size: 28rpx; + font-weight: 600; + color: #333; + margin-top: 8rpx; +} + +.suggestions-list { + display: flex; + flex-direction: column; + gap: 16rpx; + margin-top: 12rpx; +} + +.suggestion-item { + background: linear-gradient(135deg, #fce4ec 0%, #f8bbd0 100%); + padding: 16rpx 20rpx; + border-radius: 8rpx; +} + +.suggestion-text { + font-size: 26rpx; + color: #333; + line-height: 1.6; + display: block; +} + +/* ========== 简化版(无面相骨相数据) ========== */ +.simple-career-list { + display: flex; + flex-wrap: wrap; + gap: 16rpx; +} + +.simple-career-tag { + padding: 12rpx 24rpx; + background: #f3f4f6; + border-radius: 24rpx; + font-size: 26rpx; + color: #374151; +} + +.ai-generated-tip { + text-align: center; + font-size: 24rpx; + color: #9CA3AF; + margin-top: 24rpx; + padding-bottom: 40rpx; +} + +/* ========== 操作按钮 ========== */ +.action-section { + margin-top: 16rpx; + padding-bottom: 40rpx; +} + +.btn { + display: flex; + align-items: center; + justify-content: center; + width: 100%!important; + height: 100rpx; + min-height: 100rpx; + border-radius: 28rpx; + margin-bottom: 20rpx; + padding: 0; + border: none; + line-height: 100rpx; + box-sizing: border-box; + overflow: hidden; +} + +.btn::after { + border: none; +} + +.btn-primary { + background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%); + box-shadow: 0 8rpx 24rpx rgba(230, 57, 70, 0.3); +} + +.btn-outline { + background: #fff; + border: 2rpx solid #e63946; +} + +.btn-text { + font-size: 32rpx; + font-weight: 600; + color: #fff; +} + +.btn-text-outline { + font-size: 30rpx; + color: #e63946; + font-weight: 500; +} + +/* ========== 无人脸错误状态 ========== */ +.no-face-wrap { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 40rpx 32rpx; + box-sizing: border-box; +} + +.no-face-card { + background: #fff; + border-radius: 24rpx; + padding: 60rpx 48rpx; + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.08); +} + +.no-face-icon { + width: 120rpx; + height: 120rpx; + background: #fff3f3; + border-radius: 60rpx; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 32rpx; +} + +.no-face-emoji { + font-size: 56rpx; + line-height: 1; +} + +.no-face-title { + font-size: 38rpx; + font-weight: 700; + color: #1a1a2e; + margin-bottom: 16rpx; + text-align: center; +} + +.no-face-desc { + font-size: 28rpx; + color: #e63946; + text-align: center; + line-height: 1.6; + margin-bottom: 40rpx; +} + +.no-face-tips { + width: 100%; + background: #f8f9fa; + border-radius: 16rpx; + padding: 28rpx 32rpx; + margin-bottom: 48rpx; + box-sizing: border-box; +} + +.no-face-tips-title { + font-size: 28rpx; + font-weight: 600; + color: #333; + display: block; + margin-bottom: 16rpx; +} + +.no-face-tip-item { + font-size: 26rpx; + color: #666; + line-height: 1.8; + display: block; +} + +.no-face-btn { + width: 100%; + height: 96rpx; + background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%); + border-radius: 48rpx; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 8rpx 24rpx rgba(230, 57, 70, 0.3); +} + +.no-face-btn-text { + font-size: 32rpx; + font-weight: 600; + color: #fff; +} + +/* ========== 盖洛普优势卡片 ========== */ +.gallup-list { + display: flex; + flex-direction: column; + gap: 20rpx; + margin-top: 12rpx; +} + +.gallup-item { + display: flex; + align-items: center; + gap: 24rpx; + padding: 18rpx 24rpx; + background: linear-gradient(135deg, rgba(230, 57, 70, 0.06) 0%, rgba(255, 107, 157, 0.04) 100%); + border-radius: 16rpx; + border-left: 6rpx solid #e63946; +} + +.gallup-rank { + width: 56rpx; + height: 56rpx; + border-radius: 50%; + background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%); + display: flex; + align-items: center; + justify-content: center; + font-size: 28rpx; + font-weight: 700; + color: #fff; + flex-shrink: 0; + text-align: center; + line-height: 56rpx; +} + +.gallup-text { + font-size: 30rpx; + font-weight: 600; + color: #1a1a2e; +} + +/* ========== 企业版:职业画像 / HR视角 / 老板视角 ========== */ +.enterprise-section { + margin-bottom: 24rpx; +} + +.enterprise-section:last-child { + margin-bottom: 0; +} + +.enterprise-label { + font-size: 26rpx; + font-weight: 600; + color: #333; + display: block; + margin-bottom: 12rpx; +} + +.enterprise-sublabel { + font-size: 24rpx; + color: #888; + display: block; + margin-bottom: 8rpx; +} + +.enterprise-tags { + display: flex; + flex-wrap: wrap; + gap: 12rpx; +} + +.enterprise-tag { + padding: 10rpx 20rpx; + background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%); + border-radius: 24rpx; + font-size: 24rpx; + color: #2e7d32; +} + +.enterprise-tag.risk { + background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%); + color: #e65100; +} + +.lifecycle-list { + display: flex; + flex-direction: column; + gap: 16rpx; +} + +.lifecycle-item { + padding: 16rpx 20rpx; + background: #f8f9fa; + border-radius: 12rpx; +} + +.lifecycle-key { + font-size: 24rpx; + color: #999; + display: block; + margin-bottom: 6rpx; +} + +.lifecycle-val { + font-size: 26rpx; + color: #333; + line-height: 1.6; + display: block; +} + +.boss-card { + border-left: 6rpx solid #1976d2; +} + +.boss-headline { + font-size: 30rpx; + font-weight: 600; + color: #1a1a2e; + line-height: 1.6; + display: block; + margin-bottom: 20rpx; +} + +.boss-metrics { + display: flex; + flex-wrap: wrap; + gap: 16rpx; +} + +.boss-metric { + flex: 1; + min-width: 140rpx; + padding: 16rpx; + background: #f5f5f5; + border-radius: 12rpx; + text-align: center; +} + +.boss-metric-label { + font-size: 22rpx; + color: #999; + display: block; + margin-bottom: 6rpx; +} + +.boss-metric-value { + font-size: 28rpx; + font-weight: 600; + color: #333; + display: block; +} + +.boss-metric-value.high { + color: #4caf50; +} + +.boss-metric-value.medium { + color: #ff9800; +} + +.boss-metric-value.low { + color: #f44336; +} diff --git a/miniprogram/pages/index/upload.js b/miniprogram/pages/index/upload.js new file mode 100644 index 0000000..6287649 --- /dev/null +++ b/miniprogram/pages/index/upload.js @@ -0,0 +1,193 @@ +// pages/index/camera.js - 面相分析拍照页,拍完后上传到服务器再跳转结果页 +const app = getApp() +const { hasPhone, bindPhoneByCode, ensureProfileCompleteAndRedirect } = require('../../utils/phoneAuth.js') + +Page({ + data: { + sampleImages: [ + 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/20260312/img_69b22c5b77dd72.66364633.png', + 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/20260312/img_69b22d71b312f8.14596541.png', + 'https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/20260312/img_69b22d78d52b94.76297015.png' + ], + photos: ['', '', ''], + uploadedUrls: [], + photoIndex: 0, + guideTexts: ['请正对镜头', '请向左转45°', '请向右转45°'], + guideText: '请正对镜头', + uploading: false, + needPhoneAuth: false, + aiAnalysisText: '智能分析' + }, + + onLoad() { + const tc = app.globalData.textConfig + if (tc && tc.aiAnalysisText) { + this.setData({ aiAnalysisText: tc.aiAnalysisText }) + } else { + app.getRuntimeConfig().then((cfg) => { + if (cfg && cfg.textConfig) { + app.globalData.textConfig = cfg.textConfig + this.setData({ aiAnalysisText: cfg.textConfig.aiAnalysisText || '智能分析' }) + } + }).catch(() => {}) + } + }, + + onShow() { + if (!ensureProfileCompleteAndRedirect()) return + if (typeof this.getTabBar === 'function' && this.getTabBar()) { + this.getTabBar().setData({ selected: 1 }) + } + this.setData({ needPhoneAuth: !hasPhone() }) + const tc = app.globalData.textConfig + if (tc && tc.aiAnalysisText) { + this.setData({ aiAnalysisText: tc.aiAnalysisText }) + } + }, + + // 本页就地授权手机号 + onGetPhoneNumber(e) { + const { code, errMsg } = e.detail || {} + if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) { + wx.showToast({ title: '需要授权手机号才能继续', icon: 'none' }) + return + } + if (!code && !hasPhone()) { + wx.showToast({ title: '获取手机号失败', icon: 'none' }) + return + } + if (!code && hasPhone()) { + // 已有手机号,无需重复请求 + this.setData({ needPhoneAuth: false }) + return + } + bindPhoneByCode(code).then(() => { + this.setData({ needPhoneAuth: false }) + }).catch(() => { + // 失败时保持 needPhoneAuth 为 true,等待用户重新授权 + this.setData({ needPhoneAuth: !hasPhone() }) + }) + }, + + // 选择/拍摄照片通用方法(index: 0 正面 / 1 左侧 / 2 右侧) + choosePhoto(index) { + wx.chooseImage({ + count: 1, + sizeType: ['compressed'], + sourceType: ['camera', 'album'], + success: (res) => { + const tempPath = res.tempFilePaths[0] + const photos = this.data.photos.slice() + photos[index] = tempPath + const filledCount = photos.filter(Boolean).length + const guideText = this.data.guideTexts[filledCount] || '拍摄完成' + this.setData({ photos, photoIndex: filledCount, guideText }) + // 选完图后立即上传当前这张到服务器 + this.uploadSinglePhoto(index, tempPath) + }, + fail: () => {} + }) + }, + // 单张上传到服务器,成功后记录 URL + uploadSinglePhoto(index, localPath) { + const apiBase = (app.globalData && app.globalData.apiBase) ? app.globalData.apiBase.replace(/\/$/, '') : '' + if (!apiBase) { + wx.showToast({ title: '网络未就绪,请稍后再试', icon: 'none' }) + return + } + const token = (app.globalData && app.globalData.token) || wx.getStorageSync('token') || '' + const uploadUrl = apiBase + '/api/upload/image' + + wx.showLoading({ title: '上传中...', mask: true }) + wx.uploadFile({ + url: uploadUrl, + filePath: localPath, + name: 'file', + header: token ? { Authorization: 'Bearer ' + token } : {}, + success: (res) => { + try { + const data = JSON.parse(res.data) + if (data.code === 200 && data.data && data.data.url) { + const url = data.data.url + const uploadedUrls = this.data.uploadedUrls.slice() + uploadedUrls[index] = url + const photos = this.data.photos.slice() + photos[index] = url + this.setData({ uploadedUrls, photos }) + } else { + wx.showToast({ title: data.message || '上传失败', icon: 'none' }) + } + } catch (e) { + wx.showToast({ title: '解析上传结果失败', icon: 'none' }) + } + }, + fail: (err) => { + console.error('[upload] 单张上传失败:', err) + wx.showToast({ title: '上传失败,请重试', icon: 'none' }) + }, + complete: () => { + wx.hideLoading() + } + }) + }, + + // 三个角度对应的上传按钮 + onUploadFront() { + this.choosePhoto(0) + }, + onUploadLeft() { + this.choosePhoto(1) + }, + onUploadRight() { + this.choosePhoto(2) + }, + + // 重新拍摄全部照片 + retakeAll() { + wx.showModal({ + title: '重新拍摄', + content: '确定要重新拍摄所有照片吗?', + confirmText: '确定', + cancelText: '取消', + success: (res) => { + if (res.confirm) { + this.setData({ + photos: ['', '', ''], + uploadedUrls: [], + photoIndex: 0, + guideText: '请正对镜头' + }) + wx.showToast({ title: '已清空,请重新拍摄', icon: 'success' }) + } + } + }) + }, + + // 完成拍照:先上传 3 张图到服务器,拿到 URL 后再跳转结果页 + completeCapture() { + if (!ensureProfileCompleteAndRedirect()) return + const urls = (this.data.uploadedUrls || []).filter(Boolean) + if (!urls.length) { + wx.showToast({ title: '请先上传至少一张照片', icon: 'none' }) + return + } + // 此时 URL 已在选择时上传完成,这里只负责保存和跳转 + wx.setStorageSync('aiPhotos', urls) + wx.navigateTo({ url: '/pages/index/result' }) + }, + + // 相机错误 + onCameraError(e) { + console.error('相机错误:', e) + wx.showModal({ + title: '相机权限', + content: '请允许使用相机权限以进行AI人脸分析', + confirmText: '去设置', + success: (res) => { + if (res.confirm) { + wx.openSetting() + } + } + }) + } +}) diff --git a/miniprogram/pages/index/upload.json b/miniprogram/pages/index/upload.json new file mode 100644 index 0000000..19388ac --- /dev/null +++ b/miniprogram/pages/index/upload.json @@ -0,0 +1,8 @@ +{ + "navigationBarTitleText": "拍照", + "navigationBarBackgroundColor": "#FFFFFF", + "navigationBarTextStyle": "black", + "usingComponents": { + "custom-tab-bar": "/custom-tab-bar/index" + } +} diff --git a/miniprogram/pages/index/upload.wxml b/miniprogram/pages/index/upload.wxml new file mode 100644 index 0000000..4b81bce --- /dev/null +++ b/miniprogram/pages/index/upload.wxml @@ -0,0 +1,113 @@ + + + + + + + 多角度拍摄更精准 + + 为了更准确地通过骨相与面相分析您的性格, + 请上传以下三个维度的照片。 + + + + + + + + + 1 + + + 正面 + 目视前方,五官端正,不遮挡额头 + + + + + + 📷 + 点击上传 + + + + + + + + + + 2 + + + 左侧 45度 + 展示左侧面颊,用于分析面部轮廓 + + + + + + 📷 + 点击上传 + + + + + + + + + + 3 + + + 右侧 45度 + 展示右侧面颊,完整捕捉面部信息 + + + + + + 📷 + 点击上传 + + + + + + + + + + + + 温馨提示 + + + · 光线充足,背景整洁,避免过曝或过暗 + · 保持表情自然,无需刻意微笑 + · 如有佩戴眼镜,请确保镜片不反光 + + + + + + 为保障服务与联系,请先授权手机号。 + + + + + + + + + + + + diff --git a/miniprogram/pages/index/upload.wxss b/miniprogram/pages/index/upload.wxss new file mode 100644 index 0000000..01914ff --- /dev/null +++ b/miniprogram/pages/index/upload.wxss @@ -0,0 +1,243 @@ +/* pages/index/camera.wxss - 上传照片引导页(参照设计稿) */ + +.page { + min-height: 100vh; + min-height: 100dvh; + background-color: #ffffff; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +/* 主体内容 */ +.content { + flex: 1; + /* 底部按钮作为正常内容,在 tabBar 之上留出空间 */ + padding: 40rpx 48rpx 40rpx; + box-sizing: border-box; +} + +/* 顶部说明文案 */ +.intro { + text-align: center; + margin-bottom: 40rpx; +} + +.intro-title { + font-size: 34rpx; + font-weight: 700; + color: #111827; + margin-bottom: 12rpx; +} + +.intro-desc-wrap { + display: flex; + flex-direction: column; + align-items: center; + row-gap: 4rpx; +} + +.intro-desc { + font-size: 24rpx; + color: #6b7280; + line-height: 1.7; +} + +/* 三个步骤区域 */ +.steps { + display: flex; + flex-direction: column; + row-gap: 40rpx; +} + +.step-item { + display: flex; + flex-direction: row; +} + +.step-index { + width: 60rpx; + height: 60rpx; + border-radius: 9999rpx; + border-width: 4rpx; + display: flex; + align-items: center; + justify-content: center; + margin-right: 24rpx; +} + +.step-index-primary { + background-color: #ffe5e9; + border-color: #ff5e7d; +} + +.step-index-secondary { + background-color: #f3f4f6; + border-color: #d1d5db; +} + +.step-index-text { + font-size: 26rpx; + font-weight: 700; +} + +.step-index-text-primary { + color: #ff5e7d; +} + +.step-index-text-secondary { + color: #6b7280; +} + +.step-body { + flex: 1; +} + +.step-title { + font-size: 30rpx; + font-weight: 700; + color: #111827; + margin-bottom: 4rpx; +} + +.step-subtitle { + font-size: 22rpx; + color: #9ca3af; + margin-bottom: 16rpx; +} + +.step-upload-row { + display: flex; + flex-direction: row; + column-gap: 24rpx; +} + +.sample-image { + width: 200rpx; + height: 200rpx; + border-radius: 24rpx; + border: 2rpx solid #f3f4f6; + box-shadow: 0 6rpx 16rpx rgba(0, 0, 0, 0.06); + flex-shrink: 0; +} + +.upload-card { + width: 200rpx; + height: 200rpx; + overflow: hidden; + border-radius: 24rpx; + border: 4rpx dashed #fecaca; + background-color: #fff5f5; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #f97373; +} + +.upload-icon { + font-size: 40rpx; + margin-bottom: 8rpx; +} + +.upload-text { + font-size: 24rpx; +} + +.upload-preview { + width: 100%; + height: 100%; + border-radius: 20rpx; + display: block; +} + +/* 温馨提示卡片 */ +.tips-card { + margin-top: 48rpx; + padding: 24rpx 28rpx; + border-radius: 24rpx; + background-color: #fff5f5; + border: 2rpx solid #ffe0e0; +} + +.tips-title-row { + display: flex; + align-items: center; + margin-bottom: 12rpx; +} + +.tips-icon { + width: 28rpx; + height: 28rpx; + border-radius: 9999rpx; + border: 4rpx solid #e63946; + margin-right: 10rpx; +} + +.tips-title { + font-size: 26rpx; + font-weight: 700; + color: #e63946; +} + +.tips-list { + margin-top: 4rpx; +} + +.tips-item { + font-size: 24rpx; + color: #b91c1c; + line-height: 1.7; +} + +/* 底部按钮区域 */ +.footer { + padding: 24rpx 0 0; + margin: 24rpx 48rpx calc(160rpx + env(safe-area-inset-bottom)); + background-color: #ffffff; +} + +.footer .primary-btn{ + width: 100%; +} + +.phone-auth-section { + padding: 24rpx 24rpx 0; + display: flex; + flex-direction: column; + gap: 16rpx; +} + +.phone-auth-tip { + font-size: 26rpx; + color: #4b5563; +} + +.phone-auth-btn { + height: 80rpx; + line-height: 80rpx; + border-radius: 40rpx; + background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%); + color: #ffffff; + font-size: 28rpx; +} + +.phone-auth-btn::after { + border: none; +} + +.primary-btn { + width: 100%; + padding: 24rpx 0; + border-radius: 9999rpx; + border: none; + background-image: linear-gradient(90deg, #ff5e7d 0%, #ff85a1 100%); + box-shadow: 0 10rpx 25rpx rgba(248, 113, 113, 0.45); + font-size: 32rpx; + font-weight: 700; + color: #ffffff; +} + +.primary-btn::after { + border: none; +} diff --git a/miniprogram/pages/phone-auth/index.js b/miniprogram/pages/phone-auth/index.js new file mode 100644 index 0000000..886de17 --- /dev/null +++ b/miniprogram/pages/phone-auth/index.js @@ -0,0 +1,74 @@ +// 手机号授权页:用户点击按钮授权后,用 code 换手机号并写回 userInfo,再返回或跳转 next +const app = getApp() + +Page({ + data: { + next: '', + }, + + onLoad(options) { + this.setData({ + next: options.next ? decodeURIComponent(options.next) : '', + }) + }, + + onGetPhoneNumber(e) { + const { code, errMsg } = e.detail || {} + if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) { + wx.showToast({ title: '需要授权手机号才能继续', icon: 'none' }) + return + } + if (!code) { + wx.showToast({ title: '获取手机号失败', icon: 'none' }) + return + } + const token = app.globalData.token || wx.getStorageSync('token') + if (!token) { + wx.showToast({ title: '请先登录', icon: 'none' }) + return + } + wx.showLoading({ title: '处理中...', mask: true }) + wx.request({ + url: `${app.globalData.apiBase.replace(/\/$/, '')}/api/auth/wechat/phone`, + method: 'POST', + header: { + 'Authorization': 'Bearer ' + token, + 'Content-Type': 'application/json', + }, + data: { code }, + success: (res) => { + wx.hideLoading() + if (res.statusCode === 200 && res.data && res.data.code === 200) { + const data = res.data.data || {} + const user = data.user || app.globalData.userInfo || {} + const phone = data.phone || user.phone || '' + const newUser = { ...user, phone } + app.globalData.userInfo = newUser + wx.setStorageSync('userInfo', newUser) + wx.showToast({ title: '授权成功', icon: 'success' }) + const nextPath = this.data.next && this.data.next.startsWith('/') ? this.data.next : '' + const tabBarPaths = ['/pages/index/index', '/pages/index/camera', '/pages/profile/index'] + const isTabBar = tabBarPaths.some(p => nextPath === p || nextPath.startsWith(p + '?')) + if (nextPath) { + setTimeout(() => { + if (isTabBar) { + const pathOnly = nextPath.split('?')[0] + wx.switchTab({ url: pathOnly, fail: () => wx.navigateBack() }) + } else { + wx.redirectTo({ url: nextPath, fail: () => wx.navigateBack() }) + } + }, 500) + } else { + setTimeout(() => wx.navigateBack(), 500) + } + } else { + wx.showToast({ title: res.data && res.data.message ? res.data.message : '获取手机号失败', icon: 'none' }) + } + }, + fail: () => { + wx.hideLoading() + wx.showToast({ title: '网络请求失败', icon: 'none' }) + }, + }) + }, +}) diff --git a/miniprogram/pages/phone-auth/index.json b/miniprogram/pages/phone-auth/index.json new file mode 100644 index 0000000..4d34557 --- /dev/null +++ b/miniprogram/pages/phone-auth/index.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "授权手机号", + "usingComponents": {} +} diff --git a/miniprogram/pages/phone-auth/index.wxml b/miniprogram/pages/phone-auth/index.wxml new file mode 100644 index 0000000..eecad2b --- /dev/null +++ b/miniprogram/pages/phone-auth/index.wxml @@ -0,0 +1,5 @@ + + 为保障服务与联系需要,请授权您的手机号。 + 授权后可在「查看报告」「支付」「企业咨询」等场景使用,仅需授权一次。 + + diff --git a/miniprogram/pages/phone-auth/index.wxss b/miniprogram/pages/phone-auth/index.wxss new file mode 100644 index 0000000..2e079d3 --- /dev/null +++ b/miniprogram/pages/phone-auth/index.wxss @@ -0,0 +1,29 @@ +.container { + padding: 48rpx 32rpx; + min-height: 100vh; + box-sizing: border-box; +} +.tip { + font-size: 30rpx; + color: #333; + line-height: 1.6; + margin-bottom: 24rpx; +} +.tip.sub { + color: #666; + font-size: 26rpx; + margin-bottom: 64rpx; +} +.auth-btn { + width: 100%; + height: 88rpx; + line-height: 88rpx; + background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%); + color: #fff; + border-radius: 44rpx; + font-size: 32rpx; + border: none; +} +.auth-btn::after { + border: none; +} diff --git a/miniprogram/pages/profile/index.js b/miniprogram/pages/profile/index.js new file mode 100644 index 0000000..2608062 --- /dev/null +++ b/miniprogram/pages/profile/index.js @@ -0,0 +1,318 @@ +// pages/profile/index.js - 我的页面 +const app = getApp() +const { getTypeOnly } = require('../../utils/resultFormat') +const { request } = require('../../utils/request') + +Page({ + data: { + hasLogin: false, + userInfo: null, + balance: 0, + testCount: 0, + hasResults: false, + mbtiType: '', + discType: '', + pdpType: '', + aiType: '', + mbtiTime: '', + discTime: '', + pdpTime: '', + aiTime: '', + /** 最近记录的数据库 ID,用于跳转时传参 */ + mbtiResultId: null, + discResultId: null, + pdpResultId: null, + aiResultId: null, + loginLoading: false, + loginFailed: false, + nicknameDisplay: '', + displayNickname: '', + /** 默认头像(根据昵称):无头像时显示首字+背景色 */ + avatarLetter: '登', + avatarBgColor: '#6366f1', + // 推广中心统计与配置(来自 /api/distribution/stats) + promoDistributionEnabled: true, + promoCenterTitle: '推广中心', + promoTotalInvite: 0, + promoTotalEarned: '0.00', + promoWithdrawable: '0.00', + /** 是否有企业权限(绑定企业):有则显示「我的简历」 */ + hasEnterprise: false + }, + + onLoad() { + // 仅在 onLoad 执行一次,onShow 里的 runLoginThenLoad 会导致重复请求 + this.runLoginThenLoad() + }, + onShow() { + // 如果是从其他页面返回,且已经登录,则只刷新数据而不重新执行登录流程 + if (this.data.hasLogin) { + this.loadData() + } + + if (typeof this.getTabBar === 'function' && this.getTabBar()) { + this.getTabBar().setData({ selected: 2 }) + } + }, + + /** 先确保登录完成(静默登录),再刷新页面数据 */ + runLoginThenLoad() { + this.setData({ loginLoading: true, loginFailed: false }) + app.ensureLogin().then((ok) => { + this.setData({ loginLoading: false, loginFailed: !ok }) + this.loadData() + }).catch(() => { + this.setData({ loginLoading: false, loginFailed: true }) + this.loadData() + }) + }, + + /** 生成随机后缀:仅 26 英文字母(大小写)与数字,不含特殊字符,默认 4 位 */ + _randomWechatUserSuffix(len) { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + let s = '' + for (let i = 0; i < (len || 4); i++) { + s += chars[Math.floor(Math.random() * chars.length)] + } + return s + }, + + /** 根据昵称生成默认头像的首字与背景色(同一昵称同色) */ + _avatarFromNickname(name) { + const str = (name && String(name).trim()) || '登' + const letter = str.charAt(0).toUpperCase() || '登' + const palette = ['#6366f1', '#8b5cf6', '#ec4899', '#f43f5e', '#14b8a6', '#0ea5e9', '#3b82f6', '#eab308'] + let hash = 0 + for (let i = 0; i < str.length; i++) hash += str.charCodeAt(i) + const bgColor = palette[Math.abs(hash) % palette.length] + return { avatarLetter: letter, avatarBgColor: bgColor } + }, + + /** 获取或生成当前用户的「微信用户XXXX」后缀(同一用户固定,仅 26 字母+数字) */ + _getOrCreateWechatUserSuffix(userInfo) { + const uid = (userInfo && (userInfo.id || userInfo.userId)) ? String(userInfo.id || userInfo.userId) : '_default' + const storageKey = 'wechat_user_suffix' + const stored = wx.getStorageSync(storageKey) + if (stored && typeof stored === 'object' && stored[uid]) return stored[uid] + const suffix = this._randomWechatUserSuffix(4) + const next = { ...(stored && typeof stored === 'object' ? stored : {}), [uid]: suffix } + wx.setStorageSync(storageKey, next) + return suffix + }, + + loadData() { + const userInfo = app.globalData.userInfo || wx.getStorageSync('userInfo') + const token = app.globalData.token || wx.getStorageSync('token') + + // 1. 先同步渲染用户基础信息(昵称/头像) + const nickname = (userInfo && (userInfo.nickname || userInfo.nickName)) + ? String(userInfo.nickname || userInfo.nickName).trim() : '' + let nicknameDisplay = nickname + let displayNickname = '' + if (!nickname && userInfo) { + const suffix = this._getOrCreateWechatUserSuffix(userInfo) + displayNickname = '微信用户' + suffix + nicknameDisplay = displayNickname + } + const avatarFromName = userInfo + ? this._avatarFromNickname(nicknameDisplay) + : this._avatarFromNickname('点击登录') + + const hasEnterprise = !!(userInfo && (userInfo.hasEnterprise === true || (userInfo.enterpriseId && Number(userInfo.enterpriseId) > 0))) + this.setData({ + hasLogin: !!token || !!userInfo, + userInfo: userInfo || null, + hasEnterprise, + nicknameDisplay, + displayNickname, + avatarLetter: avatarFromName.avatarLetter, + avatarBgColor: avatarFromName.avatarBgColor + }) + + // 2. 已登录则从服务端拉取最近记录;失败降级读 localStorage + if (token || userInfo) { + this._loadRecentFromAPI() + this._loadPromoStats() + } + }, + + /** 从 /api/test/recent 拉取各类型最新记录 */ + _loadRecentFromAPI() { + const scope = app.globalData.appScope || 'personal' + request({ + url: `/api/test/recent?scope=${scope}`, + method: 'GET', + success: (res) => { + // res 是 wx.request 原始响应:{ statusCode, data: { code, data, message } } + const payload = res && res.data + if (!payload || payload.code !== 200 || !payload.data) { + this._loadRecentFromStorage() + return + } + const { records = {}, totalCount = 0 } = payload.data + const r = records + + // DISC resultText 后端已含「型」,type badge 只显示字母,去掉「型」 + const discType = r.disc ? r.disc.resultText.replace(/型$/, '') : '' + + this.setData({ + testCount: totalCount, + hasResults: !!(r.mbti || r.disc || r.pdp || r.ai), + mbtiType: r.mbti ? r.mbti.resultText : '', + discType, + pdpType: r.pdp ? r.pdp.resultText : '', + aiType: r.ai ? r.ai.resultText : '', + mbtiTime: r.mbti ? r.mbti.testTime : '', + discTime: r.disc ? r.disc.testTime : '', + pdpTime: r.pdp ? r.pdp.testTime : '', + aiTime: r.ai ? r.ai.testTime : '', + mbtiResultId: r.mbti ? r.mbti.id : null, + discResultId: r.disc ? r.disc.id : null, + pdpResultId: r.pdp ? r.pdp.id : null, + aiResultId: r.ai ? r.ai.id : null, + }) + }, + fail: () => { + this._loadRecentFromStorage() + } + }) + }, + + /** 从 /api/distribution/stats 拉取推广中心统计 */ + _loadPromoStats() { + request({ + url: '/api/distribution/stats', + method: 'GET', + success: (res) => { + const payload = res && res.data + if (payload && payload.code === 200 && payload.data) { + const d = payload.data + this.setData({ + promoDistributionEnabled: d.distributionEnabled !== false, + promoCenterTitle: d.promoCenterTitle || '推广中心', + promoTotalInvite: d.totalInvite || 0, + promoTotalEarned: d.totalEarned || '0.00', + promoWithdrawable: d.walletBalance || '0.00' + }) + } + } + }) + }, + + /** 降级:从 localStorage 读最近记录(兼容离线或 API 失败) */ + _loadRecentFromStorage() { + const mbtiResult = wx.getStorageSync('mbtiResult') + const discResult = wx.getStorageSync('discResult') + const pdpResult = wx.getStorageSync('pdpResult') + const aiResult = wx.getStorageSync('aiResult') + + let testCount = 0 + if (mbtiResult) testCount++ + if (discResult) testCount++ + if (pdpResult) testCount++ + if (aiResult) testCount++ + + const _fmt = (ts) => { + if (!ts) return '' + const d = new Date(typeof ts === 'number' && ts < 1e12 ? ts * 1000 : ts) + if (isNaN(d.getTime())) return '' + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` + } + + this.setData({ + testCount, + hasResults: testCount > 0, + mbtiType: mbtiResult ? getTypeOnly(mbtiResult, 'mbti') : '', + discType: discResult ? getTypeOnly(discResult, 'disc') : '', + pdpType: pdpResult ? getTypeOnly(pdpResult, 'pdp') : '', + aiType: aiResult ? (aiResult.mbtiType || aiResult.type || '') : '', + mbtiTime: _fmt(mbtiResult && (mbtiResult.createdAt || mbtiResult.timestamp || mbtiResult.testTime)), + discTime: _fmt(discResult && (discResult.createdAt || discResult.timestamp || discResult.testTime)), + pdpTime: _fmt(pdpResult && (pdpResult.createdAt || pdpResult.timestamp || pdpResult.testTime)), + aiTime: _fmt(aiResult && (aiResult.createdAt || aiResult.timestamp || aiResult.testTime)), + mbtiResultId: null, + discResultId: null, + pdpResultId: null, + aiResultId: null, + }) + }, + + /** 点击登录:先静默登录拿到 token */ + doLogin() { + this.setData({ loginLoading: true, loginFailed: false }) + app.ensureLogin().then((ok) => { + this.setData({ loginLoading: false }) + if (ok) { + this.loadData() + } else { + wx.showToast({ title: '登录失败,请检查网络或稍后重试', icon: 'none' }) + this.setData({ loginFailed: true }) + } + }).catch(() => { + this.setData({ loginLoading: false, loginFailed: true }) + wx.showToast({ title: '登录失败', icon: 'none' }) + }) + }, + + goToIndex() { wx.switchTab({ url: '/pages/index/index' }) }, + goToCamera() { wx.switchTab({ url: '/pages/index/camera' }) }, + goToHistory() { wx.navigateTo({ url: '/pages/history/index' }) }, + goToUserProfile() { wx.navigateTo({ url: '/pages/user-profile/index' }) }, + goToPurchase() { wx.navigateTo({ url: '/pages/purchase/index?tab=personal' }) }, + goToPurchasePersonal() { wx.navigateTo({ url: '/pages/purchase/index?tab=personal' }) }, + goToPurchaseEnterprise() { wx.navigateTo({ url: '/pages/purchase/index?tab=enterprise' }) }, + goToEnterprise() { wx.navigateTo({ url: '/pages/enterprise/index' }) }, + goToPromo() { wx.navigateTo({ url: '/pages/promo/index' }) }, + goToMyResume() { wx.navigateTo({ url: '/pages/enterprise/resume-history' }) }, + goToSettings() { + wx.showToast({ title: '开发中', icon: 'none' }) + }, + shareApp() { + // 触发分享 + }, + viewMBTI() { + const id = this.data.mbtiResultId + wx.navigateTo({ url: id ? `/pages/result/mbti?id=${id}&type=mbti` : '/pages/result/mbti' }) + }, + viewDISC() { + const id = this.data.discResultId + wx.navigateTo({ url: id ? `/pages/result/disc?id=${id}&type=disc` : '/pages/result/disc' }) + }, + viewPDP() { + const id = this.data.pdpResultId + wx.navigateTo({ url: id ? `/pages/result/pdp?id=${id}&type=pdp` : '/pages/result/pdp' }) + }, + viewAI() { + const id = this.data.aiResultId + wx.navigateTo({ url: id ? `/pages/index/result?id=${id}&type=ai` : '/pages/index/result' }) + }, + + logout() { + wx.showModal({ + title: '确认退出', + content: '退出后测试记录仍会保留', + success: (res) => { + if (res.confirm) { + app.logout() + this.setData({ hasLogin: false, userInfo: null, loginFailed: false }) + } + } + }) + }, + + onShareAppMessage() { + const { getSharePathByScope } = require('../../utils/share') + return { + title: '神仙团队AI性格测试 - 发现你的MBTI类型', + path: getSharePathByScope('/pages/index/index') + } + }, + + onShareTimeline() { + const { buildShareQuery } = require('../../utils/share') + return { + title: '神仙团队AI性格测试 - 发现你的MBTI类型', + query: buildShareQuery() + } + } +}) diff --git a/miniprogram/pages/profile/index.json b/miniprogram/pages/profile/index.json new file mode 100644 index 0000000..0d7731d --- /dev/null +++ b/miniprogram/pages/profile/index.json @@ -0,0 +1,6 @@ +{ + "navigationBarTitleText": "", + "navigationBarBackgroundColor": "#F9FAFB", + "navigationBarTextStyle": "black", + "usingComponents": {} +} diff --git a/miniprogram/pages/profile/index.wxml b/miniprogram/pages/profile/index.wxml new file mode 100644 index 0000000..89ef911 --- /dev/null +++ b/miniprogram/pages/profile/index.wxml @@ -0,0 +1,212 @@ + + + + + + 我的 + + + + + + + + + + {{avatarLetter}} + + + + 登录中... + 登录失败,点击重试 + 点击登录 + 登录后查看你的测试结果 + + + + + + + + + + + + {{avatarLetter}} + + + + + + + + {{nicknameDisplay || '点击设置昵称'}} + + + + + {{mbtiType}} + + + {{discType}}型 + + + {{pdpType}} + + + 暂无测试记录 + + + + + + + + + + + + 最新测试 + + 查看全部 + + + + + + + + + 🧠 + + MBTI性格 + {{mbtiType}} + {{mbtiTime}} + + + + + + 📊 + + DISC测评 + {{discType}}型 + {{discTime}} + + + + + + 🦁 + + PDP行为 + {{pdpType}} + {{pdpTime}} + + + + + + 👁️ + + 面相分析 + {{aiType}} + {{aiTime}} + + + + + + + + + + + 📈 + {{promoCenterTitle}} + + + + + 邀请好友 + {{promoTotalInvite}} + + + + 累计收益 + ¥{{promoTotalEarned}} + + + + 可提现 + ¥{{promoWithdrawable}} + + + + + + + + 深度服务 + + + + + 📄 + + + 专业报告 + 解锁完整的深度性格解析 + + + + + + + + + 🏢 + + + 企业版服务 + 团队测评与人才管理方案 + + + + + + + + + 🕒 + + + 测试历史 + {{testCount > 0 ? testCount + '条记录' : '查看过往所有测试记录'}} + + + + + + + + 📋 + + + 我的简历 + 查看与设置默认简历 + + + + + + + + + + diff --git a/miniprogram/pages/profile/index.wxss b/miniprogram/pages/profile/index.wxss new file mode 100644 index 0000000..252f93b --- /dev/null +++ b/miniprogram/pages/profile/index.wxss @@ -0,0 +1,534 @@ +/* pages/profile/index.wxss */ +page { + background-color: #F9FAFB; +} + +.page { + min-height: 100vh; + background-color: #F9FAFB; + padding-bottom: 140rpx; +} + +/* ===== 顶部栏 ===== */ +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16rpx 40rpx 24rpx; + background: #F9FAFB; +} + +.topbar-title { + font-size: 48rpx; + font-weight: 700; + color: #111827; + letter-spacing: -0.5rpx; +} + +.topbar-actions { + display: flex; + gap: 12rpx; + align-items: center; +} + +.settings-btn { + width: 72rpx; + height: 72rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.settings-icon { + font-size: 44rpx; + color: #4B5563; +} + +.icon-btn { + width: 72rpx; + height: 72rpx; + border-radius: 50%; + background: transparent; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; +} + +.icon-btn::after { + border: none; +} + +.icon-text { + font-size: 36rpx; +} + +/* ===== 用户卡片 ===== */ +.user-card { + margin: 0 32rpx 28rpx; + background: #FFFFFF; + border-radius: 28rpx; + padding: 36rpx 32rpx; + display: flex; + align-items: center; + gap: 20rpx; + box-shadow: 0 4rpx 24rpx rgba(0,0,0,0.05); + position: relative; +} + +.user-card:active { + opacity: 0.9; +} + +/* 头像区域(已登录,仅展示) */ +.avatar-wrap { + position: relative; + width: 144rpx; + height: 144rpx; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.avatar-ring { + width: 144rpx; + height: 144rpx; + border-radius: 50%; + padding: 4rpx; + background: linear-gradient(135deg, #FFDEE9 0%, #B5FFFC 100%); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + box-shadow: inset 0 2rpx 10rpx rgba(0,0,0,0.05); + box-sizing: border-box; +} + +.avatar-img { + width: 128rpx; + height: 128rpx; + border-radius: 50%; + border: 4rpx solid #FFFFFF; + display: block; +} + +.avatar-letter-wrap { + width: 128rpx; + height: 128rpx; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + border: 4rpx solid #FFFFFF; +} + +.avatar-letter { + font-size: 64rpx; + font-weight: 700; + color: #FFFFFF; +} + +.online-dot { + position: absolute; + bottom: 8rpx; + right: 8rpx; + width: 28rpx; + height: 28rpx; + background: #22C55E; + border-radius: 50%; + border: 4rpx solid #FFFFFF; + z-index: 10; + pointer-events: none; +} + +/* 用户信息 */ +.user-meta { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + gap: 8rpx; + padding-left: 12rpx; +} + +.user-name { + font-size: 36rpx; + font-weight: 700; + color: #111827; + display: block; +} + +.user-sub { + font-size: 24rpx; + color: #9CA3AF; +} + +.nickname-text { + font-size: 36rpx; + font-weight: 700; + color: #111827; + line-height: 1.3; + display: block; +} + +/* 类型标签 */ +.tags-scroll { + width: 100%; + white-space: nowrap; +} + +.tags-row { + display: flex; + gap: 10rpx; + flex-wrap: nowrap; +} + +.tag { + display: inline-flex; + align-items: center; + padding: 6rpx 16rpx; + border-radius: 10rpx; + flex-shrink: 0; +} + +.tag-text { + font-size: 20rpx; + font-weight: 600; + white-space: nowrap; +} + +.tag-purple { + background: #EDE9FE; +} +.tag-purple .tag-text { + color: #7C3AED; +} + +.tag-blue { + background: #DBEAFE; +} +.tag-blue .tag-text { + color: #2563EB; +} + +.tag-orange { + background: #FEF3C7; +} +.tag-orange .tag-text { + color: #D97706; +} + +.tag-gray { + background: #F3F4F6; +} +.tag-gray .tag-text { + color: #9CA3AF; +} + +.chevron { + font-size: 48rpx; + color: #D1D5DB; + flex-shrink: 0; +} + +/* ===== Section ===== */ +.section { + margin-bottom: 28rpx; +} + +.px-section { + padding: 0 32rpx; +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 32rpx; + margin-bottom: 20rpx; +} + +.section-title { + font-size: 32rpx; + font-weight: 700; + color: #1F2937; + display: block; + margin-bottom: 20rpx; +} + +.section-header .section-title { + margin-bottom: 0; +} + +.section-link { + display: flex; + align-items: center; +} + +.section-link-text { + font-size: 24rpx; + color: #F43F5E; + font-weight: 500; +} + +/* ===== 测试结果卡片(横向滚动) ===== */ +.cards-scroll { + width: 100%; + white-space: nowrap; +} + +.cards-row { + display: flex; + gap: 24rpx; + padding: 8rpx 32rpx 16rpx; + width: max-content; +} + +.result-card { + width: 280rpx; + height: 240rpx; + border-radius: 24rpx; + padding: 32rpx 28rpx 24rpx; + display: flex; + flex-direction: column; + justify-content: space-between; + position: relative; + overflow: hidden; + box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.06); + border: 1rpx solid rgba(0,0,0,0.04); + background: #FFFFFF; + flex-shrink: 0; +} + +.card-deco { + position: absolute; + top: -24rpx; + right: -24rpx; + width: 120rpx; + height: 120rpx; + border-radius: 50%; + opacity: 0.3; +} + +.card-purple .card-deco { background: #DDD6FE; } +.card-blue .card-deco { background: #BFDBFE; } +.card-orange .card-deco { background: #FDE68A; } +.card-rose .card-deco { background: #FECDD3; } + +.card-icon-wrap { + width: 64rpx; + height: 64rpx; + border-radius: 16rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.card-icon-purple { background: #EDE9FE; } +.card-icon-blue { background: #DBEAFE; } +.card-icon-orange { background: #FEF3C7; } +.card-icon-rose { background: #FFE4E6; } + +.card-icon { + font-size: 32rpx; +} + +.card-label { + font-size: 22rpx; + color: #9CA3AF; + font-weight: 500; +} + +.card-value { + font-size: 44rpx; + font-weight: 800; + letter-spacing: -1rpx; +} + +.card-purple .card-value { color: #7C3AED; } +.card-blue .card-value { color: #2563EB; } +.card-orange .card-value { color: #D97706; } +.card-rose .card-value { color: #E11D48; } + +.card-time { + font-size: 20rpx; + color: #D1D5DB; +} + +.card-time { + font-size: 20rpx; + color: #D1D5DB; +} + +/* ===== 推广中心 ===== */ +.promo-card { + background: #FFFFFF; + border-radius: 32rpx; + padding: 40rpx; + box-shadow: 0 4rpx 24rpx rgba(0,0,0,0.05); + border: 1rpx solid rgba(0,0,0,0.04); +} + +.promo-header { + display: flex; + align-items: center; + margin-bottom: 40rpx; +} + +.promo-title-wrap { + display: flex; + align-items: center; + gap: 12rpx; +} + +.promo-icon { + font-size: 32rpx; +} + +.promo-title { + font-size: 32rpx; + font-weight: 700; + color: #1F2937; +} + +.promo-stats { + display: flex; + justify-content: space-between; + align-items: center; +} + +.promo-stat-item { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 8rpx; +} + +.promo-stat-label { + font-size: 24rpx; + color: #9CA3AF; +} + +.promo-stat-value { + font-size: 36rpx; + font-weight: 700; + color: #111827; + font-family: "DIN Alternate", "Courier New", Courier, monospace; +} + +.promo-stat-divider { + width: 1rpx; + height: 60rpx; + background: #F3F4F6; +} + +.promo-highlight { + color: #F43F5E; +} + +/* ===== 服务菜单 ===== */ +.menu-card { + background: #FFFFFF; + border-radius: 24rpx; + overflow: hidden; + box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.05); + border: 1rpx solid rgba(0,0,0,0.04); +} + +.menu-item { + display: flex; + align-items: center; + padding: 32rpx 36rpx; + gap: 28rpx; + position: relative; +} + +.menu-item:active { + background: #F9FAFB; +} + +.menu-divider { + height: 1rpx; + background: #F3F4F6; + margin: 0 36rpx; +} + +.menu-icon-wrap { + width: 80rpx; + height: 80rpx; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.menu-icon-red { background: #FEF2F2; } +.menu-icon-indigo { background: #EEF2FF; } +.menu-icon-amber { background: #FFFBEB; } +.menu-icon-purple { background: #EDE9FE; } + +.menu-icon { + font-size: 36rpx; +} + +.menu-content { + flex: 1; + min-width: 0; +} + +.menu-title { + font-size: 30rpx; + font-weight: 600; + color: #111827; + display: block; + margin-bottom: 6rpx; +} + +.menu-sub { + font-size: 24rpx; + color: #9CA3AF; + display: block; +} + +.menu-chevron { + font-size: 48rpx; + color: #D1D5DB; + flex-shrink: 0; +} + +/* ===== 退出登录按钮 ===== */ +.logout-btn { + width: 100%; + padding: 32rpx; + background: #FFF1F2; + border: 1rpx solid #FFE4E6; + border-radius: 24rpx; + display: flex; + align-items: center; + justify-content: center; + gap: 16rpx; + box-sizing: border-box; +} + +.logout-btn:active { + opacity: 0.8; +} + +.logout-icon { + font-size: 32rpx; +} + +.logout-text { + font-size: 30rpx; + font-weight: 600; + color: #EF4444; +} + +.bottom-safe { + height: 40rpx; +} diff --git a/miniprogram/pages/promo/index.js b/miniprogram/pages/promo/index.js new file mode 100644 index 0000000..d0d120e --- /dev/null +++ b/miniprogram/pages/promo/index.js @@ -0,0 +1,270 @@ +// pages/promo/index.js +const app = getApp() +const { request } = require('../../utils/request') + +Page({ + data: { + balance: '0.00', + totalEarned: '0.00', + pendingAmount: '0.00', + bindingCount: 0, + paidCount: 0, + expiringCount: 0, + activeTab: 0, + userList: [], + listTotal: 0, + listPage: 1, + listLoading: false, + listFinished: false, + commissionRate: '', + bindingDays: '', + testCommissionType: '', + testCommissionRate: '', + testCommissionAmount: '', + testNoPayment: false, + withdrawMinYuan: '1.00', + withdrawMaxYuan: '', + withdrawFeePct: 0, + requireWithdrawAudit: true, + showWithdrawDialog: false, + withdrawAmountInput: '', + withdrawError: '', + withdrawFeeYuan: '0.00', + withdrawActualYuan: '0.00' + }, + + onLoad() { + this.loadStats() + this.loadBindings(true) + }, + + onShow() { + this.loadStats() + }, + + /** 加载推广统计数据 */ + loadStats() { + request({ + url: '/api/distribution/stats', + method: 'GET', + success: (res) => { + const payload = res && res.data + if (payload && payload.code === 200 && payload.data) { + const d = payload.data + const title = d.promoCenterTitle || '推广中心' + wx.setNavigationBarTitle({ title }) + this.setData({ + balance: d.walletBalance, + totalEarned: d.totalEarned, + pendingAmount: d.pendingAmount, + bindingCount: d.bindingCount, + paidCount: d.paidCount, + expiringCount: d.expiringCount, + totalInvite: d.totalInvite, + commissionRate: d.commissionRate, + bindingDays: d.bindingDays, + testCommissionType: d.testCommissionType, + testCommissionRate: d.testCommissionRate, + testCommissionAmount: d.testCommissionAmount, + testNoPayment: d.testNoPayment, + withdrawMinYuan: d.withdrawMinYuan != null ? d.withdrawMinYuan : '1.00', + withdrawMaxYuan: d.withdrawMaxYuan != null && d.withdrawMaxYuan !== '' ? d.withdrawMaxYuan : '', + withdrawFeePct: d.withdrawFeePct != null ? d.withdrawFeePct : 0, + requireWithdrawAudit: d.requireWithdrawAudit !== false, + }) + } + } + }) + }, + + /** 加载绑定用户列表(tab: 0=绑定中 1=已付款 2=已过期) */ + loadBindings(reset = false) { + if (this.data.listLoading) return + if (!reset && this.data.listFinished) return + + const page = reset ? 1 : this.data.listPage + this.setData({ listLoading: true }) + + request({ + url: `/api/distribution/bindings?tab=${this.data.activeTab}&page=${page}&pageSize=10`, + method: 'GET', + success: (res) => { + const payload = res && res.data + if (payload && payload.code === 200 && payload.data) { + const { list, total } = payload.data + const formatted = (list || []).map(item => ({ + ...item, + createdAtStr: item.createdAt ? this._fmtTimestamp(item.createdAt) : '' + })) + const newList = reset ? formatted : [...this.data.userList, ...formatted] + this.setData({ + userList: newList, + listTotal: total, + listPage: page + 1, + listFinished: newList.length >= total, + }) + } + }, + complete: () => { + this.setData({ listLoading: false }) + } + }) + }, + + /** 时间戳格式化为 YYYY-MM-DD */ + _fmtTimestamp(ts) { + if (!ts) return '' + const d = new Date(ts * 1000) + if (isNaN(d.getTime())) return '' + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${day}` + }, + + /** 切换用户列表 Tab */ + switchTab(e) { + const index = parseInt(e.currentTarget.dataset.index) + if (index === this.data.activeTab) return + this.setData({ activeTab: index, userList: [], listPage: 1, listFinished: false }) + this.loadBindings(true) + }, + + /** 上拉加载更多 */ + onReachBottom() { + this.loadBindings(false) + }, + + /** 申请提现:打开自定义金额弹框 */ + handleWithdraw() { + const balance = parseFloat(this.data.balance) + if (balance < 1) { + wx.showToast({ title: '余额不足1元,暂无法提现', icon: 'none' }) + return + } + const pct = this.data.withdrawFeePct || 0 + const feeYuan = (balance * pct / 100).toFixed(2) + const actualYuan = (balance - balance * pct / 100).toFixed(2) + this.setData({ + showWithdrawDialog: true, + withdrawAmountInput: this.data.balance, + withdrawFeeYuan: feeYuan, + withdrawActualYuan: actualYuan, + withdrawError: '' + }) + }, + + /** 关闭提现弹框 */ + closeWithdrawDialog() { + this.setData({ + showWithdrawDialog: false, + withdrawError: '' + }) + }, + + /** 输入金额:实时计算手续费与实际到账 */ + onWithdrawInput(e) { + const raw = e.detail.value + const val = parseFloat(raw) + const pct = this.data.withdrawFeePct || 0 + let feeYuan = '0.00' + let actualYuan = '0.00' + if (raw !== '' && !isNaN(val) && val >= 0) { + const fee = val * pct / 100 + feeYuan = fee.toFixed(2) + actualYuan = (val - fee).toFixed(2) + } + this.setData({ + withdrawAmountInput: raw, + withdrawFeeYuan: feeYuan, + withdrawActualYuan: actualYuan, + withdrawError: '' + }) + }, + + /** 确认提现(使用用户填写的金额) */ + confirmWithdraw() { + const balance = parseFloat(this.data.balance) + const val = parseFloat(this.data.withdrawAmountInput) + const minYuan = parseFloat(this.data.withdrawMinYuan) || 1 + const pct = this.data.withdrawFeePct || 0 + + if (isNaN(val)) { + this.setData({ withdrawError: '请输入正确的金额' }) + return + } + if (val < 1) { + this.setData({ withdrawError: '单次提现金额至少 1 元' }) + return + } + if (val > balance) { + this.setData({ withdrawError: '不可超过当前可提现金额' }) + return + } + const actualYuan = val - val * pct / 100 + if (actualYuan < minYuan) { + this.setData({ withdrawError: `实际到账金额不得低于最低提现金额 ¥${minYuan.toFixed(2)}` }) + return + } + + const amountFen = Math.floor(val * 100) + this.setData({ withdrawError: '' }) + + request({ + url: '/api/distribution/withdraw', + method: 'POST', + data: { amountFen }, + success: (r) => { + const payload = r && r.data + if (payload && payload.code === 200) { + const msg = payload.msg || payload.message || '' + wx.showToast({ title: '申请已提交', icon: 'success' }) + this.setData({ showWithdrawDialog: false }) + this.loadStats() + // 免审核且已自动发起微信转账时,自动进入提现记录页 + if (msg.indexOf('已自动发起') !== -1) { + setTimeout(() => { + wx.navigateTo({ url: '/pages/promo/withdrawals' }) + }, 500) + } + } else { + const errMsg = (payload && (payload.msg || payload.message)) || '申请失败,请稍后重试' + this.setData({ withdrawError: errMsg }) + } + } + }) + }, + + /** 查看提现记录 */ + goToWithdrawHistory() { + wx.navigateTo({ url: '/pages/promo/withdrawals' }) + }, + + /** 生成海报 */ + generatePoster() { + wx.navigateTo({ url: '/pages/promo/poster' }) + }, + + /** 分享到朋友圈:引导用户使用右上角菜单 */ + shareToTimeline() { + wx.showToast({ title: '请点击右上角 ··· 选择「分享到朋友圈」', icon: 'none', duration: 2500 }) + }, + + /** 分享给好友 */ + onShareAppMessage() { + const { getSharePathByScope } = require('../../utils/share') + return { + title: '神仙团队AI性格测试 - 发现你的内在潜能', + path: getSharePathByScope('/pages/index/index') + } + }, + + /** 分享到朋友圈 */ + onShareTimeline() { + const { buildShareQuery } = require('../../utils/share') + return { + title: '神仙团队AI性格测试 - 发现你的内在潜能', + query: buildShareQuery() + } + } +}) diff --git a/miniprogram/pages/promo/index.json b/miniprogram/pages/promo/index.json new file mode 100644 index 0000000..1996801 --- /dev/null +++ b/miniprogram/pages/promo/index.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "推广中心", + "navigationBarBackgroundColor": "#FFFFFF", + "navigationBarTextStyle": "black" +} \ No newline at end of file diff --git a/miniprogram/pages/promo/index.wxml b/miniprogram/pages/promo/index.wxml new file mode 100644 index 0000000..edc9f4d --- /dev/null +++ b/miniprogram/pages/promo/index.wxml @@ -0,0 +1,219 @@ + + + + + + + + 🧧 + + + 可提现金额 + + + {{commissionRate}}% 高额返利 + + + + + ¥{{balance}} + 累计: ¥{{totalEarned}} | 待审核: ¥{{pendingAmount}} + + + + + + + 查看提现记录 + + + + + + + + 申请提现 + 可提现 ¥{{balance}},请输入本次提现金额 + + + ¥ + + + + 本次最高可提 ¥{{balance}} + + + 手续费 ¥{{withdrawFeeYuan}} + 实际到账 ¥{{withdrawActualYuan}} + + + {{withdrawError}} + + + + 取消 + 确定 + + + + + + + + {{bindingCount}} + 绑定中 + + + {{paidCount}} + 已付款 + + + {{expiringCount}} + 即将过期 + + + {{totalInvite}} + 总邀请 + + + + + + + + 🛡️ + + 推广收益规则 + + + + + 用户绑定有效期为 {{bindingDays}}天,期满自动解除 + + + 💰 + 单笔提现:最低 ¥{{withdrawMinYuan}} + 最高 ¥{{withdrawMaxYuan}} + 不设上限 + + + 📋 + 提现手续费:{{withdrawFeePct}}% + + + + + + + + + 👥 + 绑定用户 + ({{totalInvite}}) + + + + + + + 绑定中 ({{bindingCount}}) + + + 已付款 ({{paidCount}}) + + + 已过期 ({{expiringCount}}) + + + + + + + 🔍 + + 目前还没有绑定的用户哦 + 快去分享链接邀请好友吧 + + + + + + + + + {{item.nickname ? item.nickname[0] : '?'}} + + + + + + {{item.remainDays}}天到期 + 已过期 + 已付款 + + + + + + 加载中... + + + — 已加载全部 — + + + + + + + + + 🎨 + + + 生成推广海报 + 一键生成您的专属精美海报 + + + + + + 💬 + + + 分享到朋友圈 + 通过右上角菜单分享到朋友圈 + + + + + + + + + 分享专属链接,好友通过链接点击后自动绑定 + 购买任意测评即可获得 90% 的现金返利 + + + + diff --git a/miniprogram/pages/promo/index.wxss b/miniprogram/pages/promo/index.wxss new file mode 100644 index 0000000..66fb8be --- /dev/null +++ b/miniprogram/pages/promo/index.wxss @@ -0,0 +1,550 @@ +/* pages/promo/index.wxss */ +page { + background-color: #FFFFFF; +} + +.container { + padding: 32rpx; + background-color: #FFFFFF; +} + +/* ===== 顶部统计卡片 ===== */ +.hero-card { + background: #FFF5F6; + border-radius: 40rpx; + padding: 40rpx; + box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.02); + position: relative; + overflow: hidden; + margin-bottom: 40rpx; +} + +.hero-header-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 48rpx; +} + +.hero-left { + display: flex; + align-items: center; + gap: 20rpx; +} + +.wallet-icon { + width: 88rpx; + height: 88rpx; + background: #FFFFFF; + border-radius: 24rpx; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2rpx 10rpx rgba(0,0,0,0.03); +} + +.emoji { font-size: 44rpx; } + +.title-text .label { + font-size: 24rpx; + color: #6B7280; + font-weight: 500; +} + +.badge { + display: flex; + align-items: center; + gap: 6rpx; + margin-top: 6rpx; +} + +.badge .dot { + width: 10rpx; + height: 10rpx; + background: #F43F5E; + border-radius: 50%; +} + +.badge-text { + font-size: 22rpx; + color: #F43F5E; + font-weight: 600; +} + +.hero-right { + text-align: right; +} + +.amount { + font-size: 72rpx; + font-weight: 700; + color: #111827; + line-height: 1; +} + +.amount-sub { + font-size: 22rpx; + color: #9CA3AF; + margin-top: 16rpx; + display: block; +} + +.withdraw-btn { + width: 100%; + height: 104rpx; + background: #F3F4F6; + color: #9CA3AF; + border-radius: 52rpx; + font-size: 30rpx; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 32rpx; + border: none; +} + +.withdraw-btn::after { border: none; } + +.withdraw-btn:not(.disabled) { + background: #F43F5E; + color: #FFFFFF; +} + +.record-link { + display: flex; + align-items: center; + justify-content: center; + gap: 6rpx; + font-size: 26rpx; + color: #F43F5E; + font-weight: 600; +} + +/* ===== 四格统计 ===== */ +.stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 24rpx; + margin-bottom: 40rpx; +} + +.stat-item { + background: #FFFFFF; + padding: 28rpx 16rpx; + border-radius: 32rpx; + border: 1rpx solid #F3F4F6; + text-align: center; + box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.04); +} + +.stat-val { + font-size: 40rpx; + font-weight: 800; + color: #111827; + display: block; + margin-bottom: 4rpx; +} + +.stat-val.highlight { color: #F59E0B; } + +.stat-label { + font-size: 22rpx; + color: #6B7280; + font-weight: 500; +} + +/* ===== 通用 Section ===== */ +.section { + background: #FFFFFF; + border-radius: 48rpx; + border: 1rpx solid #F3F4F6; + box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.04); + margin-bottom: 40rpx; + overflow: hidden; +} + +.section-header { + padding: 40rpx; + display: flex; + align-items: center; + gap: 16rpx; +} + +.section-header.border-b { + border-bottom: 1rpx solid #F9FAFB; +} + +.header-icon { + width: 56rpx; + height: 56rpx; + border-radius: 16rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.header-icon.red { background: #FFF1F2; } + +.section-title { + font-size: 28rpx; + font-weight: 800; + color: #111827; +} + +.count-label { + font-size: 24rpx; + color: #9CA3AF; + font-weight: 400; + margin-left: 8rpx; +} + +/* 规则列表 */ +.rule-list { + padding: 0 40rpx 40rpx; + display: flex; + flex-direction: column; + gap: 28rpx; +} + +.rule-item { + display: flex; + gap: 24rpx; + align-items: flex-start; +} + +.rule-bullet { + font-size: 20rpx; + margin-top: 4rpx; +} + +.rule-text { + font-size: 24rpx; + color: #6B7280; + line-height: 1.5; +} + +.highlight { color: #F43F5E; font-weight: 700; } + +/* 用户列表 Tab */ +.tab-bar { + display: flex; + background: #F9FAFB; + margin: 16rpx 32rpx; + padding: 8rpx; + border-radius: 24rpx; +} + +.tab-item { + flex: 1; + text-align: center; + padding: 20rpx 0; + font-size: 24rpx; + color: #6B7280; + font-weight: 500; + border-radius: 16rpx; + transition: all 0.2s; +} + +.tab-item.active { + background: #FFFFFF; + color: #F43F5E; + font-weight: 800; + box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.05); +} + +.empty-state { + padding: 100rpx 80rpx; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; +} + +.empty-icon { + width: 160rpx; + height: 160rpx; + background: linear-gradient(to top right, #F9FAFB, #FFFFFF); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 32rpx; + box-shadow: inset 0 2rpx 10rpx rgba(0,0,0,0.02); +} + +.empty-text { + font-size: 28rpx; + color: #9CA3AF; + font-weight: 600; +} + +.empty-sub { + font-size: 20rpx; + color: #D1D5DB; + margin-top: 8rpx; +} + +/* ===== 操作菜单 ===== */ +.menu-card { + background: #FFFFFF; + border-radius: 48rpx; + border: 1rpx solid #F3F4F6; + box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.04); + overflow: hidden; + margin-bottom: 40rpx; +} + +.menu-item { + display: flex; + align-items: center; + padding: 32rpx 40rpx; + border-bottom: 1rpx solid #F9FAFB; + background: #FFFFFF; + text-align: left; + line-height: 1.4; +} + +.menu-item:last-child { border-bottom: none; } + +.menu-item.share-btn { + width: 100%; + margin: 0; + border-radius: 0; +} + +.menu-item.share-btn::after { border: none; } + +.menu-icon-wrap { + width: 88rpx; + height: 88rpx; + border-radius: 32rpx; + display: flex; + align-items: center; + justify-content: center; + margin-right: 32rpx; +} + +.menu-icon-wrap.rose { background: #FFF1F2; color: #F43F5E; } +.menu-icon-wrap.emerald { background: #ECFDF5; color: #10B981; } +.menu-icon-wrap.violet { background: #F5F3FF; color: #8B5CF6; } + +.menu-info { flex: 1; } + +.menu-name { + font-size: 28rpx; + font-weight: 800; + color: #111827; + display: block; +} + +.menu-desc { + font-size: 22rpx; + color: #6B7280; + display: block; + margin-top: 4rpx; +} + +.menu-arrow { + font-size: 36rpx; + color: #D1D5DB; +} + +/* ===== 底部提示 ===== */ +.footer-tip { + padding: 40rpx 60rpx; + text-align: center; +} + +.footer-tip text { + display: block; + font-size: 22rpx; + color: #D1D5DB; + line-height: 1.6; + font-weight: 500; +} + +/* ===== 绑定用户列表条目 ===== */ +.user-item { + display: flex; + align-items: center; + padding: 28rpx 32rpx; + border-bottom: 1rpx solid #F3F4F6; +} +.user-item:last-child { border-bottom: none; } + +.user-avatar { + width: 80rpx; + height: 80rpx; + border-radius: 50%; + flex-shrink: 0; + margin-right: 24rpx; + background: #F3F4F6; +} +.user-avatar-placeholder { + display: flex; + align-items: center; + justify-content: center; + background: #FECDD3; +} +.user-avatar-letter { + font-size: 36rpx; + color: #F43F5E; + font-weight: 700; +} + +.user-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 6rpx; +} +.user-name { + font-size: 28rpx; + font-weight: 600; + color: #1F2937; +} +.user-time { + font-size: 22rpx; + color: #9CA3AF; +} + +.user-badge { + font-size: 22rpx; + font-weight: 600; + padding: 6rpx 18rpx; + border-radius: 20rpx; + flex-shrink: 0; +} +.badge-active { + background: #FEF3C7; + color: #D97706; +} +.badge-paid { + background: #D1FAE5; + color: #059669; +} +.badge-expired { + background: #F3F4F6; + color: #9CA3AF; +} + +/* 列表底部提示 */ +.list-footer { + padding: 32rpx 0; + text-align: center; +} +.list-footer-text { + font-size: 24rpx; + color: #D1D5DB; +} + +.safe-bottom { height: 60rpx; } + +/* ===== 提现金额弹框 ===== */ +.withdraw-mask { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} + +.withdraw-dialog { + width: 640rpx; + padding: 40rpx 40rpx 32rpx; + border-radius: 32rpx; + background: #ffffff; + box-shadow: 0 24rpx 60rpx rgba(15, 23, 42, 0.25); +} + +.dialog-title { + font-size: 32rpx; + font-weight: 700; + color: #111827; +} + +.dialog-sub { + margin-top: 8rpx; + font-size: 24rpx; + color: #6b7280; +} + +.amount-input-row { + margin-top: 32rpx; + padding: 16rpx 20rpx; + border-radius: 20rpx; + background: #f9fafb; + display: flex; + align-items: center; +} + +.currency { + font-size: 32rpx; + font-weight: 600; + color: #111827; + margin-right: 8rpx; +} + +.amount-input { + flex: 1; + font-size: 32rpx; + font-weight: 600; + color: #111827; +} + +.amount-input::placeholder { + color: #9ca3af; +} + +.amount-hint { + margin-top: 8rpx; + font-size: 22rpx; + color: #9ca3af; +} +.fee-actual-row { + margin-top: 12rpx; + font-size: 24rpx; + color: #6b7280; +} +.fee-actual-row .actual { + margin-left: 24rpx; + color: #059669; + font-weight: 500; +} + +.error-text { + margin-top: 8rpx; + font-size: 22rpx; + color: #dc2626; +} + +.dialog-actions { + margin-top: 32rpx; + display: flex; + gap: 16rpx; +} + +.dialog-actions .btn { + flex: 1; + height: 80rpx; + border-radius: 9999rpx; + display: flex; + align-items: center; + justify-content: center; + font-size: 28rpx; + font-weight: 600; +} + +.dialog-actions .btn.cancel { + background: #f3f4f6; + color: #4b5563; +} + +.dialog-actions .btn.confirm { + background: #f43f5e; + color: #ffffff; +} + diff --git a/miniprogram/pages/promo/poster.js b/miniprogram/pages/promo/poster.js new file mode 100644 index 0000000..1c6186e --- /dev/null +++ b/miniprogram/pages/promo/poster.js @@ -0,0 +1,74 @@ +const { getApiBase } = require('../../utils/request') + +Page({ + data: { + loading: true, + loadingText: '正在生成海报...', + posterUrl: '' + }, + + onLoad() { + this.loadPoster() + }, + + /** 从后端接口下载完整合成海报 */ + loadPoster() { + const app = getApp() + const gd = app.globalData || {} + const storedUser = wx.getStorageSync('userInfo') || {} + const userInfo = gd.userInfo || storedUser + const apiBase = getApiBase() + const token = wx.getStorageSync('token') || gd.token || '' + const scope = gd.appScope || 'personal' + // 企业 ID:按优先级取 scene > globalData.userInfo > storage.userInfo + const eid = scope === 'enterprise' + ? (gd.enterpriseIdFromScene || (gd.userInfo && gd.userInfo.enterpriseId) || storedUser.enterpriseId || null) + : null + let url = apiBase.replace(/\/$/, '') + '/api/distribution/poster' + if (eid) url += `?eid=${eid}&scope=enterprise` + else if (scope === 'enterprise') url += '?scope=enterprise' // 企业模式但 eid 未知,后端从 DB 取 + else url += '?scope=personal' + + wx.downloadFile({ + url, + header: token ? { Authorization: 'Bearer ' + token } : {}, + success: (res) => { + if (res.statusCode === 200 && res.tempFilePath) { + this.setData({ posterUrl: res.tempFilePath, loading: false }) + } else { + this.setData({ loadingText: '海报生成失败', loading: false }) + wx.showToast({ title: '生成失败', icon: 'none' }) + } + }, + fail: () => { + this.setData({ loadingText: '请求失败,请重试', loading: false }) + wx.showToast({ title: '请求失败', icon: 'none' }) + } + }) + }, + + /** 保存到相册 */ + savePoster() { + if (this.data.loading || !this.data.posterUrl) return + wx.showLoading({ title: '正在保存...' }) + wx.saveImageToPhotosAlbum({ + filePath: this.data.posterUrl, + success: () => { + wx.hideLoading() + wx.showToast({ title: '已保存到相册', icon: 'success' }) + }, + fail: (err) => { + wx.hideLoading() + if (err.errMsg && err.errMsg.indexOf('auth deny') >= 0) { + wx.showModal({ + title: '提示', + content: '需要您授权保存图片到相册', + success: (r) => { if (r.confirm) wx.openSetting() } + }) + } else { + wx.showToast({ title: '保存失败', icon: 'none' }) + } + } + }) + } +}) diff --git a/miniprogram/pages/promo/poster.json b/miniprogram/pages/promo/poster.json new file mode 100644 index 0000000..4fc8ed5 --- /dev/null +++ b/miniprogram/pages/promo/poster.json @@ -0,0 +1,6 @@ +{ + "navigationBarTitleText": "生成推广海报", + "navigationBarBackgroundColor": "#FFFFFF", + "navigationBarTextStyle": "black", + "usingComponents": {} +} \ No newline at end of file diff --git a/miniprogram/pages/promo/poster.wxml b/miniprogram/pages/promo/poster.wxml new file mode 100644 index 0000000..a88c0ae --- /dev/null +++ b/miniprogram/pages/promo/poster.wxml @@ -0,0 +1,16 @@ + + + + + + {{loadingText}} + + + + + 保存到相册后可分享至朋友圈 + + + diff --git a/miniprogram/pages/promo/poster.wxss b/miniprogram/pages/promo/poster.wxss new file mode 100644 index 0000000..44742b1 --- /dev/null +++ b/miniprogram/pages/promo/poster.wxss @@ -0,0 +1,83 @@ +.container { + min-height: 100vh; + background-color: #f8fafc; + display: flex; + flex-direction: column; + align-items: center; + padding: 40rpx 0; +} + +.poster-preview { + position: relative; + width: 600rpx; + min-height: 800rpx; + background-color: #fff; + border-radius: 32rpx; + box-shadow: 0 20rpx 50rpx rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +.poster-img { + width: 100%; + display: block; +} + +.loading-mask { + position: absolute; + top: 0; left: 0; right: 0; bottom: 0; + background-color: rgba(255, 255, 255, 0.9); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 10; +} + +.loading-spinner { + width: 60rpx; height: 60rpx; + border: 4rpx solid #f3f4f6; + border-top: 4rpx solid #f43f5e; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 20rpx; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.loading-text { font-size: 24rpx; color: #64748b; } + +.action-bar { + position: fixed; + bottom: 0; left: 0; right: 0; + background-color: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(10px); + padding: 40rpx; + display: flex; + flex-direction: column; + align-items: center; + border-radius: 40rpx 40rpx 0 0; + box-shadow: 0 -10rpx 30rpx rgba(0, 0, 0, 0.05); +} + +.save-btn { + width: 100% !important; + height: 100rpx; + background: #f43f5e; + color: #fff; + border-radius: 24rpx; + font-size: 32rpx; + font-weight: bold; + box-shadow: 0 10rpx 20rpx rgba(244, 63, 94, 0.2); + margin-bottom: 20rpx; +} + +.save-btn[disabled] { + background: #cbd5e1; + box-shadow: none; +} + +.tip-text { font-size: 24rpx; color: #94a3b8; margin-bottom: 20rpx; } +.safe-bottom { height: env(safe-area-inset-bottom); } diff --git a/miniprogram/pages/promo/withdrawals.js b/miniprogram/pages/promo/withdrawals.js new file mode 100644 index 0000000..29354a3 --- /dev/null +++ b/miniprogram/pages/promo/withdrawals.js @@ -0,0 +1,236 @@ +// pages/promo/withdrawals.js - 提现记录列表页 +const { request } = require('../../utils/request') + +Page({ + data: { + list: [], + page: 1, + pageSize: 10, + total: 0, + loading: false, + finished: false, + // 确认收款按钮 loading 状态(按单条) + confirmingId: null + }, + + onLoad() { + this.loadData(true) + }, + + // 加载提现记录列表(reset 为 true 时重置分页) + loadData(reset = false) { + if (this.data.loading) return + if (!reset && this.data.finished) return + + const page = reset ? 1 : this.data.page + this.setData({ loading: true }) + + request({ + url: `/api/distribution/withdrawals?page=${page}&pageSize=${this.data.pageSize}`, + method: 'GET', + success: (res) => { + const payload = res && res.data + if (payload && payload.code === 200 && payload.data) { + const { list = [], total = 0 } = payload.data + const mapped = list.map(item => { + const statusCode = this._normalizeStatusCode(item.status) + const amountYuan = item.amountYuan != null + ? item.amountYuan + : (item.amountFen != null ? (item.amountFen / 100).toFixed(2) : '') + + const balanceAfterYuan = item.balanceAfterYuan != null + ? item.balanceAfterYuan + : (item.balanceAfterFen != null ? (item.balanceAfterFen / 100).toFixed(2) : '') + + const feeYuan = item.feeYuan != null + ? item.feeYuan + : (item.feeFen != null ? (item.feeFen / 100).toFixed(2) : '0.00') + + return { + ...item, + statusCode, + amountYuan, + balanceAfterYuan, + feeYuan, + // 申请时间:用 createdAt + applyAtStr: this._fmtTime(item.createdAt), + // 处理时间:用 transferAt(如有) + handleAtStr: this._fmtTime(item.transferAt), + statusTagText: this._statusTagText(statusCode), + statusTagClass: this._statusTagClass(statusCode) + } + }) + const newList = reset ? mapped : [...this.data.list, ...mapped] + this.setData({ + list: newList, + total, + page: page + 1, + finished: newList.length >= total + }) + } + }, + complete: () => { + this.setData({ loading: false }) + } + }) + }, + + // 时间戳格式化 + _fmtTime(ts) { + if (!ts) return '' + const d = new Date(ts * 1000) + if (isNaN(d.getTime())) return '' + 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}` + }, + + // 将后端 status(数字 / 中文 / 英文)统一转成数字枚举: + // 0=审核中,1=已驳回,2=待收款,3=已收款,4=已过期 + _normalizeStatusCode(status) { + if (typeof status === 'number') return status + if (typeof status === 'string') { + const s = status.trim() + if (/^\d+$/.test(s)) return parseInt(s, 10) + switch (s) { + case 'pending': + case '审核中': + return 0 + case 'rejected': + case '已驳回': + return 1 + case '待收款': + return 2 + case 'transferred': + case '已收款': + return 3 + case '已过期': + return 4 + default: + return 0 + } + } + return 0 + }, + + // 状态中文文案 + _statusTagText(code) { + switch (code) { + case 0: + return '待审核' + case 1: + return '已驳回' + case 2: + return '待收款' + case 3: + return '已收款' + case 4: + return '已过期' + default: + return '' + } + }, + + // 顶部右侧小标签样式 + _statusTagClass(code) { + switch (code) { + case 0: + return 'tag-pending' + case 1: + return 'tag-rejected' + case 2: + return 'tag-wait' + case 3: + return 'tag-transferred' + case 4: + return 'tag-expired' + default: + return '' + } + }, + + // 用户点击单条「确认收款」 + handleConfirmReceipt(e) { + const id = e.currentTarget.dataset.id + if (!id || this.data.confirmingId === id) return + + const item = (this.data.list || []).find(x => String(x.id) === String(id)) + if (!item) { + wx.showToast({ title: '记录不存在', icon: 'none' }) + return + } + + // 兼容驼峰 / 下划线字段 + const packageInfo = item.packageInfo || item.package_info + if (!packageInfo) { + wx.showToast({ title: '该提现单无法确认收款,请联系客服', icon: 'none' }) + return + } + + if (!wx.canIUse || !wx.canIUse('requestMerchantTransfer')) { + wx.showModal({ + content: '当前微信版本过低,无法使用确认收款功能,请升级微信后重试', + showCancel: false + }) + return + } + + this.setData({ confirmingId: id }) + + wx.showLoading({ title: '正在调起确认收款...' }) + + // 获取 AppID:优先记录里的,其次运行时获取 + let appId = item.appId || item.app_id + try { + if (!appId && wx.getAccountInfoSync) { + const info = wx.getAccountInfoSync() + appId = info && info.miniProgram && info.miniProgram.appId + } + } catch (err) {} + + // 商户号:从记录中取(后端已写入 mch_id) + const mchId = item.mchId || item.mch_id + + wx.requestMerchantTransfer({ + mchId, + appId, + // 文档里字段名是 package,对应后台返回的 package_info + package: packageInfo, + success: (res) => { + console.log('requestMerchantTransfer success', res) + wx.showToast({ title: '已调起收款确认', icon: 'none', duration: 2000 }) + // 主动查询转账状态并更新订单,实现及时刷新(参考商户单号查询:https://pay.weixin.qq.com/doc/v3/merchant/4012716437) + request({ + url: '/api/distribution/withdrawals/query-transfer', + method: 'POST', + data: { id: item.id }, + success: (r) => { + const p = r && r.data + if (p && p.code === 200 && p.data && p.data.status === 3) { + wx.showToast({ title: '已收款', icon: 'success' }) + } + }, + complete: () => { + this.loadData(true) + } + }) + }, + fail: (err) => { + console.error('requestMerchantTransfer fail', err) + wx.showToast({ title: '调起失败,请稍后重试', icon: 'none' }) + }, + complete: () => { + wx.hideLoading() + this.setData({ confirmingId: null }) + } + }) + }, + + onReachBottom() { + this.loadData(false) + } +}) + diff --git a/miniprogram/pages/promo/withdrawals.json b/miniprogram/pages/promo/withdrawals.json new file mode 100644 index 0000000..35f1b24 --- /dev/null +++ b/miniprogram/pages/promo/withdrawals.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "提现记录", + "usingComponents": {} +} + diff --git a/miniprogram/pages/promo/withdrawals.wxml b/miniprogram/pages/promo/withdrawals.wxml new file mode 100644 index 0000000..3505ee8 --- /dev/null +++ b/miniprogram/pages/promo/withdrawals.wxml @@ -0,0 +1,77 @@ + + + + + + + + 📭 + + 暂无提现记录 + 成功发起提现后,将在这里展示进度 + + + + + + + + 单号:{{item.out_bill_no}} + + {{item.statusTagText}} + + + + + + + ¥{{item.amountYuan}} + + + 手续费 + ¥{{item.feeYuan}} + + + 提现后余额 + ¥{{item.balanceAfterYuan}} + + + + + + 申请时间:{{item.applyAtStr}} + 处理时间:{{item.handleAtStr}} + + + + + 拒绝原因: + {{item.auditNote}} + + + + + + + + + + 加载中... + + + — 已加载全部 — + + + + + + + diff --git a/miniprogram/pages/promo/withdrawals.wxss b/miniprogram/pages/promo/withdrawals.wxss new file mode 100644 index 0000000..d06f78b --- /dev/null +++ b/miniprogram/pages/promo/withdrawals.wxss @@ -0,0 +1,244 @@ +.page { + min-height: 100vh; + background: #f9fafb; + padding: 24rpx 32rpx 32rpx; + box-sizing: border-box; +} + +.header { + margin-bottom: 24rpx; +} + +.title { + font-size: 32rpx; + font-weight: 700; + color: #111827; +} + +.sub-title { + margin-top: 6rpx; + font-size: 24rpx; + color: #6b7280; +} + +.empty { + margin-top: 80rpx; + display: flex; + flex-direction: column; + align-items: center; + color: #9ca3af; +} + +.empty-icon { + width: 120rpx; + height: 120rpx; + border-radius: 60rpx; + background: #f3f4f6; + display: flex; + align-items: center; + justify-content: center; + font-size: 56rpx; + margin-bottom: 16rpx; +} + +.empty-text { + font-size: 28rpx; + margin-bottom: 4rpx; + color: #4b5563; +} + +.empty-sub { + font-size: 24rpx; + color: #9ca3af; +} + +.list { + margin-top: 12rpx; +} + +.item { + background: #ffffff; + border-radius: 24rpx; + padding: 24rpx 28rpx; + margin-bottom: 20rpx; + box-shadow: 0 4rpx 16rpx rgba(15, 23, 42, 0.04); +} + +.item-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16rpx; +} + +.order-no { + font-size: 24rpx; + color: #6b7280; +} + +.status-tag { + padding: 6rpx 16rpx; + border-radius: 999rpx; + font-size: 22rpx; +} + +.tag-pending { + background: #e5e7eb; + color: #4b5563; +} + +.tag-approved { + background: #dbeafe; + color: #1d4ed8; +} + +.tag-wait { + background: #dbeafe; + color: #1d4ed8; +} + +.tag-rejected { + background: #fee2e2; + color: #b91c1c; +} + +.tag-transferred { + background: #dcfce7; + color: #15803d; +} + +.tag-expired { + background: #f3f4f6; + color: #9ca3af; +} + +.item-top { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.amount-block { + display: flex; + flex-direction: column; + gap: 6rpx; +} + +.amount { + font-size: 32rpx; + font-weight: 700; + color: #111827; +} + +.fee-block { + display: flex; + flex-direction: column; + gap: 4rpx; +} + +.fee-label { + font-size: 22rpx; + color: #9ca3af; +} + +.fee-value { + font-size: 26rpx; + color: #6b7280; +} + +.balance-block { + display: flex; + flex-direction: column; + align-items: flex-end; +} + +.balance-label { + font-size: 22rpx; + color: #9ca3af; +} + +.balance-value { + margin-top: 4rpx; + font-size: 26rpx; + color: #111827; +} + +.time-block { + margin-top: 16rpx; + display: flex; + flex-direction: column; + gap: 6rpx; +} + +.time-row { + font-size: 22rpx; + color: #6b7280; +} + +.reject-box { + margin-top: 16rpx; + padding: 16rpx; + border-radius: 12rpx; + background: #fef2f2; +} + +.reject-title { + font-size: 24rpx; + font-weight: 600; + color: #b91c1c; +} + +.reject-text { + font-size: 24rpx; + color: #b91c1c; +} + +.item-bottom { + margin-top: 10rpx; +} + +.note { + font-size: 24rpx; + color: #6b7280; +} + +.footer { + padding: 16rpx 0; + text-align: center; +} + +.footer-text { + font-size: 24rpx; + color: #9ca3af; +} + +.confirm-wrapper { + margin-top: 20rpx; + padding: 0 8rpx 4rpx; +} + +.confirm-btn { + width: 100%; + height: 80rpx; + border-radius: 999rpx; + background: linear-gradient(135deg, #FF6B8A 0%, #FF8E53 100%); + color: #ffffff; + font-size: 30rpx; + font-weight: 600; + box-shadow: 0 10rpx 18rpx rgba(255, 107, 138, 0.35); + border: none; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.confirm-btn[disabled] { + opacity: 0.7; + box-shadow: none; +} + +.safe-bottom { + height: calc(32rpx + env(safe-area-inset-bottom)); +} + diff --git a/miniprogram/pages/purchase/index.js b/miniprogram/pages/purchase/index.js new file mode 100644 index 0000000..ee6cb7b --- /dev/null +++ b/miniprogram/pages/purchase/index.js @@ -0,0 +1,247 @@ +// pages/purchase/index.js - 开通会员(深度服务价格:个人/企业区分,类目由后端配置可新增) +const app = getApp() +const payment = require('../../utils/payment') +const { hasPhone, bindPhoneByCode, ensureProfileCompleteAndRedirect } = require('../../utils/phoneAuth.js') + +Page({ + data: { + activeTab: 'personal', + personalCategories: [], + enterpriseCategories: [], + loading: true, + purchasing: false, + hasPhone: false, + successModal: { + visible: false, + title: '', + content: '', + wechat: '' + } + }, + + onLoad(options) { + const tab = (options && options.tab === 'enterprise') ? 'enterprise' : 'personal' + this.setData({ activeTab: tab }) + wx.setNavigationBarTitle({ title: tab === 'enterprise' ? '开通企业版' : '开通个人版' }) + this.loadDeepPricing() + }, + + onShow() { + if (!ensureProfileCompleteAndRedirect()) return + this.setData({ hasPhone: hasPhone() }) + }, + + loadDeepPricing() { + const apiBase = app.globalData.apiBase || '' + if (!apiBase) { + this.setData({ loading: false }) + return + } + this.setData({ loading: true }) + Promise.all([ + this.requestDeepPricing('personal'), + this.requestDeepPricing('enterprise') + ]).then(([personal, enterprise]) => { + this.setData({ + personalCategories: personal || [], + enterpriseCategories: enterprise || [], + loading: false + }) + }).catch(() => { + this.setData({ loading: false }) + }) + }, + + requestDeepPricing(scope) { + return new Promise((resolve) => { + wx.request({ + url: `${app.globalData.apiBase.replace(/\/$/, '')}/api/config/deep-pricing`, + method: 'GET', + data: { scope }, + success: (res) => { + if (res.statusCode === 200 && res.data && res.data.code === 200 && Array.isArray(res.data.data && res.data.data.categories)) { + resolve(res.data.data.categories) + } else { + resolve([]) + } + }, + fail: () => resolve([]) + }) + }) + }, + + switchTab(e) { + const tab = e.currentTarget.dataset.tab + this.setData({ activeTab: tab }) + }, + + // 无需再次授权时,直接点击按钮执行购买/咨询 + handlePurchaseTap(e) { + const tab = e.currentTarget.dataset.tab + const index = e.currentTarget.dataset.index + this.handlePurchase(tab, index) + }, + + // 实际执行购买/咨询逻辑(已确保有手机号) + handlePurchase(tab, index) { + if (!ensureProfileCompleteAndRedirect()) return + if (index === undefined || index === null) return + const list = tab === 'enterprise' ? this.data.enterpriseCategories : this.data.personalCategories + const category = list[index] + if (!category) return + + if (category.actionType === 'buy' && category.productKey) { + this.purchasePersonal(category) + } else { + this.applyConsult(category) + } + }, + + // 购买/企业咨询按钮:就地触发微信系统手机号授权,然后执行 handlePurchase + onGetPhoneNumberForPurchase(e) { + const tab = e.currentTarget.dataset.tab + const index = e.currentTarget.dataset.index + const { code, errMsg } = e.detail || {} + + if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) { + if (!hasPhone()) { + wx.showToast({ title: '需要授权手机号才能继续', icon: 'none' }) + return + } + // 用户拒绝但之前已授权过,本地已有手机号,则直接继续 + this.handlePurchase(tab, index) + return + } + + if (!code) { + if (hasPhone()) { + this.handlePurchase(tab, index) + } else { + wx.showToast({ title: '获取手机号失败', icon: 'none' }) + } + return + } + + bindPhoneByCode(code) + .then(() => { + this.setData({ hasPhone: true }) + this.handlePurchase(tab, index) + }) + .catch(() => { + // 失败时只提示,不阻塞后续再次点击 + }) + }, + + purchasePersonal(category) { + if (this.data.purchasing) return + this.setData({ purchasing: true }) + wx.showLoading({ title: '处理中...', mask: true }) + const deepProductId = category.id || category.productKey || '' + const title = category.title || '个人深度服务(1v1深度解读)' + payment.purchasePersonalDeepService({ + deepProductId, + description: title, + success: () => { + wx.hideLoading() + this.setData({ purchasing: false }) + this._reportCrmLead(category, 'buy') + const successMsg = (category.successMessage || '购买成功!我们的顾问会尽快与您联系,为您提供专属深度解读服务。').trim() + const wechat = (category.serviceWechat || '').trim() + this._showSuccessModal('购买成功', successMsg, wechat) + }, + fail: () => { + wx.hideLoading() + this.setData({ purchasing: false }) + } + }) + }, + + applyConsult(category) { + // serviceWechat 展示给用户,consultWechat 是存客宝 API key + const wechat = (category.serviceWechat || '').trim() + const apiKey = (category.consultWechat || '').trim() + const successMsg = (category.successMessage || '感谢您的申请,我们的顾问会尽快与您联系!').trim() + wx.showLoading({ title: '提交中...', mask: true }) + if (apiKey) { + this._reportCrmLead(category, 'consult') + } + setTimeout(() => { + wx.hideLoading() + this._showSuccessModal('申请成功', successMsg, wechat) + }, 600) + }, + + _showSuccessModal(title, content, wechat) { + this.setData({ + successModal: { + visible: true, + title: title || '成功', + content: content || '', + wechat: wechat || '' + } + }) + }, + + closeSuccessModal() { + this.setData({ 'successModal.visible': false }) + }, + + copyWechat() { + const wechat = this.data.successModal.wechat + if (!wechat) return + wx.setClipboardData({ + data: wechat, + success: () => wx.showToast({ title: '已复制微信号', icon: 'success' }) + }) + }, + + /** + * 向后端上报存客宝线索,后端负责签名和调用存客宝 API + * @param {Object} category 深度服务类目对象(需含 consultWechat / title) + * @param {string} actionType 'buy'(付款完成)| 'consult'(申请咨询) + */ + _reportCrmLead(category, actionType) { + const apiKey = category.consultWechat || '' + if (!apiKey) return + const apiBase = app.globalData.apiBase || '' + if (!apiBase) return + + const isEnterprise = this.data.activeTab === 'enterprise' + const source = (isEnterprise ? '企业深度服务' : '个人深度服务') + (category.title ? `-${category.title}` : '') + const remark = actionType === 'buy' ? '完成付款' : '申请咨询' + + wx.request({ + url: `${apiBase.replace(/\/$/, '')}/api/crm/report`, + method: 'POST', + header: { + Authorization: `Bearer ${wx.getStorageSync('token') || ''}`, + 'Content-Type': 'application/json', + }, + data: { + apiKey, + source, + remark, + siteTags: category.title || '', + }, + success(res) { + console.log('[CRM] 线索上报结果', res.data) + }, + fail(err) { + console.warn('[CRM] 线索上报请求失败', err) + }, + }) + }, + + onShareAppMessage() { + const { getSharePath } = require('../../utils/share') + return { title: '神仙团队AI性格测试 - 发现你的内在潜能', path: getSharePath('/pages/purchase/index') } + }, + + onShareTimeline() { + const { buildShareQuery } = require('../../utils/share') + return { + title: '神仙团队AI性格测试 - 发现你的内在潜能', + query: buildShareQuery() + } + } +}) diff --git a/miniprogram/pages/purchase/index.json b/miniprogram/pages/purchase/index.json new file mode 100644 index 0000000..de2823c --- /dev/null +++ b/miniprogram/pages/purchase/index.json @@ -0,0 +1,6 @@ +{ + "navigationBarTitleText": "开通会员", + "navigationBarBackgroundColor": "#FFFFFF", + "navigationBarTextStyle": "black", + "usingComponents": {} +} diff --git a/miniprogram/pages/purchase/index.wxml b/miniprogram/pages/purchase/index.wxml new file mode 100644 index 0000000..0a4868c --- /dev/null +++ b/miniprogram/pages/purchase/index.wxml @@ -0,0 +1,135 @@ + + + + + + + + + + + {{successModal.title}} + {{successModal.content}} + + 客服微信 + {{successModal.wechat}} + + + + + + + + + + + + 加载中... + + + + + + + {{item.title}} + + ¥ + {{item.price}} + {{item.priceUnit || '/次'}} + + {{item.subtitle}} + + + + + {{f}} + + + + + + + + + 暂无可用的个人版套餐 + + + + + + + + + {{item.title}} + {{item.subtitle}} + + + {{item.priceDisplay || '¥' + item.price}} + {{item.userLimit}} + + + + + + {{f}} + + + + + + + + + 暂无可用的企业版套餐 + + + + + + 🔒 + 微信安全支付 + + + + 即时到账开通 + + + 📞 + 7×24客服 + + + diff --git a/miniprogram/pages/purchase/index.wxss b/miniprogram/pages/purchase/index.wxss new file mode 100644 index 0000000..683da97 --- /dev/null +++ b/miniprogram/pages/purchase/index.wxss @@ -0,0 +1,789 @@ +/* pages/purchase/index.wxss - 按旧版模板重构 */ + +/* ── 成功弹窗 ── */ +.success-mask { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 999; + display: flex; + align-items: center; + justify-content: center; +} + +.success-dialog { + width: 600rpx; + background: #fff; + border-radius: 24rpx; + padding: 56rpx 40rpx 40rpx; + display: flex; + flex-direction: column; + align-items: center; +} + +.success-icon-wrap { + margin-bottom: 32rpx; +} + +.success-icon-circle { + width: 120rpx; + height: 120rpx; + border-radius: 50%; + background: linear-gradient(135deg, #22c55e, #16a34a); + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 8rpx 24rpx rgba(34, 197, 94, 0.35); +} + +.success-icon-check { + font-size: 56rpx; + color: #fff; + font-weight: bold; + line-height: 1; +} + +.success-dialog-title { + font-size: 40rpx; + font-weight: bold; + color: #1a1a1a; + margin-bottom: 20rpx; +} + +.success-dialog-content { + font-size: 28rpx; + color: #555; + text-align: center; + line-height: 1.7; + margin-bottom: 32rpx; +} + +.success-wechat-wrap { + width: 100%; + background: #f5f5f5; + border-radius: 12rpx; + padding: 20rpx 24rpx; + margin-bottom: 32rpx; + display: flex; + align-items: center; + gap: 16rpx; +} + +.success-wechat-label { + font-size: 24rpx; + color: #999; + flex-shrink: 0; +} + +.success-wechat-val { + font-size: 28rpx; + color: #333; + font-weight: 500; + flex: 1; + word-break: break-all; +} + +.success-dialog-btns { + width: 100%; + display: flex; + flex-direction: row; + gap: 16rpx; +} + +.success-btn-copy { + flex: 1; + background: #2563eb; + color: #fff; + font-size: 28rpx; + font-weight: 500; + padding: 24rpx 0; + border-radius: 12rpx; + border: none; +} + +.success-btn-close { + flex: 1; + background: #f5f5f5; + color: #333; + font-size: 28rpx; + padding: 24rpx 0; + border-radius: 12rpx; + border: none; +} + + +.container { + width: 100vw; + min-height: 100vh; + background-color: #f5f5f5; + padding-bottom: 160rpx; +} + +/* 用户权益状态 */ +.status-card { + display: flex; + align-items: center; + padding: 40rpx; + margin: 24rpx; + background: #fff; + border-radius: 16rpx; + box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04); +} + +.status-card.vip { + background: linear-gradient(135deg, #faf5ff 0%, #f3e8ff 100%); + border: 2rpx solid #9333ea; +} + +.status-icon { + font-size: 48rpx; + margin-right: 20rpx; +} + +.status-info { + flex: 1; +} + +.status-title { + display: block; + font-size: 32rpx; + font-weight: bold; + color: #333; +} + +.status-desc { + display: block; + font-size: 24rpx; + color: #666; + margin-top: 4rpx; +} + +.test-count { + text-align: center; +} + +.count-num { + display: block; + font-size: 40rpx; + font-weight: bold; + color: #9333ea; +} + +.count-label { + display: block; + font-size: 22rpx; + color: #999; +} + +/* 区块 */ +.section { + margin: 24rpx; +} + +.section-title { + display: block; + font-size: 34rpx; + font-weight: bold; + color: #333; + margin-bottom: 8rpx; +} + +.section-subtitle { + display: block; + font-size: 24rpx; + color: #999; + margin-bottom: 24rpx; +} + +/* Tabs 区域 */ +.tabs-container { + background-color: #fff; + padding: 24rpx 32rpx; + margin: 24rpx; + border-radius: 16rpx; +} + +.tabs-list { + display: flex; + background-color: #f5f5f5; + border-radius: 12rpx; + padding: 8rpx; +} + +.tab-item { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 16rpx 24rpx; + border-radius: 8rpx; + transition: all 0.3s; +} + +.tab-item.active { + background-color: #fff; +} + +.tab-icon { + font-size: 32rpx; + margin-right: 8rpx; +} + +.tab-text { + font-size: 28rpx; + color: #666; +} + +.tab-item.active .tab-text { + color: #333; + font-weight: 500; +} + +.loading-wrap { + padding: 80rpx; + text-align: center; +} + +.loading-text { + font-size: 28rpx; + color: #999; +} + +.empty-tip { + padding: 60rpx 24rpx; + text-align: center; + font-size: 28rpx; + color: #999; +} + +/* 定价卡片(已去除个人版/企业版彩色边框,统一灰色细边) */ +.pricing-card { + background-color: #fff; + border-radius: 16rpx; + padding: 40rpx; + margin-bottom: 24rpx; + border: 2rpx solid #e5e5e5; +} + +.pricing-card.featured, +.pricing-card.featured-blue { + border-color: #e5e5e5; + border-width: 2rpx; +} + +/* 企业版 header flex 布局 */ +.card-header-flex { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 32rpx; +} + +.header-left { + flex: 1; +} + +.plan-title-sm { + font-size: 36rpx; + font-weight: bold; + color: #333; + display: block; + margin-bottom: 8rpx; +} + +.plan-desc { + font-size: 24rpx; + color: #666; + display: block; +} + +.header-right { + text-align: right; +} + +.price-amount-small { + font-size: 40rpx; + font-weight: bold; + color: #333; + display: block; + margin-bottom: 4rpx; +} + +.price-limit { + font-size: 22rpx; + color: #999; + display: block; +} + +.check-icon.blue { + background-color: #2563eb; +} + +.purchase-button.secondary { + background-color: #2563eb; +} + +.card-header { + text-align: center; + margin-bottom: 32rpx; +} + +.plan-title { + font-size: 40rpx; + font-weight: bold; + color: #333; + display: block; + margin-bottom: 20rpx; +} + +.price-section { + display: flex; + align-items: baseline; + justify-content: center; + margin-bottom: 16rpx; +} + +.price-symbol { + font-size: 40rpx; + color: #333; + font-weight: bold; +} + +.price-amount { + font-size: 72rpx; + font-weight: bold; + color: #333; + margin: 0 8rpx; +} + +.price-unit { + font-size: 28rpx; + color: #666; +} + +.plan-subtitle { + font-size: 26rpx; + color: #666; + display: block; +} + +/* 功能列表 */ +.features-list { + margin-bottom: 32rpx; +} + +.feature-item { + display: flex; + align-items: flex-start; + margin-bottom: 20rpx; +} + +.feature-item:last-child { + margin-bottom: 0; +} + +.check-icon { + width: 32rpx; + height: 32rpx; + border-radius: 50%; + background-color: #22c55e; + color: #fff; + font-size: 20rpx; + display: flex; + align-items: center; + justify-content: center; + margin-right: 16rpx; + flex-shrink: 0; + text-align: center; +} + +.feature-text { + flex: 1; + font-size: 26rpx; + color: #666; + line-height: 1.6; +} + +/* 购买按钮 */ +.purchase-button { + width: 100%; + padding: 28rpx; + border-radius: 12rpx; + text-align: center; + box-sizing: border-box; +} + +.purchase-button.primary { + background-color: #9333ea; +} + +.purchase-button .button-text { + font-size: 32rpx; + color: #fff; + font-weight: 500; +} + +/* 分润机制 */ +.commission-section { + background-color: #f9fafb; + border-radius: 16rpx; + padding: 32rpx; + margin: 24rpx; +} + +.commission-header { + display: flex; + align-items: center; + margin-bottom: 24rpx; +} + +.commission-icon { + font-size: 32rpx; + margin-right: 12rpx; +} + +.commission-title { + font-size: 32rpx; + font-weight: 500; + color: #333; +} + +.commission-list { + display: flex; + flex-direction: column; + gap: 16rpx; +} + +.commission-item { + display: flex; + justify-content: space-between; + align-items: center; +} + +.commission-label { + font-size: 26rpx; + color: #666; +} + +.commission-value { + font-size: 28rpx; + font-weight: 500; + color: #333; +} + +/* VIP套餐网格 */ +.vip-cards { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16rpx; + margin-bottom: 32rpx; +} + +.vip-card { + position: relative; + background: #fff; + border: 2rpx solid #e5e5e5; + border-radius: 16rpx; + padding: 24rpx; + text-align: center; +} + +.vip-card.selected { + border-color: #9333ea; + background: #faf5ff; +} + +.vip-card.hot { + border-color: #f59e0b; +} + +.vip-tag { + position: absolute; + top: 8rpx; + right: 8rpx; + background: #9333ea; + color: #fff; + font-size: 18rpx; + padding: 4rpx 12rpx; + border-radius: 8rpx; +} + +.vip-hot-tag, +.vip-best-tag { + position: absolute; + top: -2rpx; + left: 50%; + transform: translateX(-50%); + background: #f59e0b; + color: #fff; + font-size: 18rpx; + padding: 4rpx 16rpx; + border-radius: 0 0 8rpx 8rpx; +} + +.vip-best-tag { + background: #ef4444; +} + +.vip-name { + display: block; + font-size: 26rpx; + color: #333; + font-weight: 500; + margin-top: 16rpx; + margin-bottom: 12rpx; +} + +.vip-price { + display: flex; + align-items: baseline; + justify-content: center; + margin-bottom: 8rpx; +} + +.vip-price .price-symbol { + font-size: 28rpx; + color: #333; +} + +.vip-price .price-num { + font-size: 48rpx; + font-weight: bold; + color: #333; +} + +.vip-original { + display: block; + font-size: 22rpx; + color: #999; + text-decoration: line-through; +} + +.vip-avg { + display: block; + font-size: 22rpx; + color: #9333ea; + margin-top: 4rpx; +} + +/* 次数包 */ +.count-cards { + display: flex; + gap: 16rpx; +} + +.count-card { + flex: 1; + position: relative; + background: #fff; + border: 2rpx solid #e5e5e5; + border-radius: 16rpx; + padding: 24rpx 16rpx; + text-align: center; +} + +.count-card.selected { + border-color: #9333ea; + background: #faf5ff; +} + +.count-save { + position: absolute; + top: -2rpx; + right: -2rpx; + background: #ef4444; + color: #fff; + font-size: 18rpx; + padding: 4rpx 12rpx; + border-radius: 0 8rpx 0 8rpx; +} + +.count-num-big { + font-size: 48rpx; + font-weight: bold; + color: #333; +} + +.count-unit { + font-size: 24rpx; + color: #999; + display: block; + margin-bottom: 12rpx; +} + +.count-price { + display: flex; + align-items: baseline; + justify-content: center; +} + +.count-price .price-symbol { + font-size: 24rpx; + color: #333; +} + +.count-price .price-num { + font-size: 36rpx; + font-weight: bold; + color: #333; +} + +/* 单次购买 */ +.single-list { + display: flex; + flex-direction: column; + gap: 16rpx; +} + +.single-item { + display: flex; + align-items: center; + background: #fff; + border-radius: 16rpx; + padding: 24rpx; + border: 2rpx solid #e5e5e5; +} + +.single-icon { + font-size: 36rpx; + margin-right: 16rpx; +} + +.single-info { + flex: 1; +} + +.single-name { + display: block; + font-size: 28rpx; + font-weight: 500; + color: #333; +} + +.single-desc { + display: block; + font-size: 22rpx; + color: #999; + margin-top: 4rpx; +} + +.single-price { + text-align: center; +} + +.single-price .price-num { + display: block; + font-size: 28rpx; + font-weight: bold; + color: #9333ea; +} + +.price-action { + display: block; + font-size: 22rpx; + color: #9333ea; +} + +/* 底部安全提示 */ +.safety-tips { + display: flex; + justify-content: center; + gap: 32rpx; + padding: 24rpx; + margin: 24rpx; +} + +.safety-item { + display: flex; + align-items: center; + gap: 8rpx; +} + +.safety-icon { + font-size: 24rpx; +} + +.safety-text { + font-size: 22rpx; + color: #999; +} + +.privileges { + background: #fff; + border-radius: 16rpx; + padding: 32rpx; +} + +.privilege-list { + display: flex; + flex-direction: column; + gap: 16rpx; +} + +.privilege-item { + display: flex; + align-items: center; + gap: 12rpx; +} + +.privilege-icon { + font-size: 28rpx; +} + +.privilege-text { + font-size: 26rpx; + color: #333; +} + +.bottom-placeholder { + height: 160rpx; +} + +/* 底部购买栏 */ +.bottom-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: #fff; + display: flex; + align-items: center; + padding: 24rpx 32rpx; + padding-bottom: calc(24rpx + env(safe-area-inset-bottom)); + box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.06); + z-index: 100; +} + +.price-info { + flex: 1; + display: flex; + align-items: baseline; +} + +.total-label { + font-size: 28rpx; + color: #666; +} + +.total-price { + font-size: 40rpx; + font-weight: bold; + color: #e63946; +} + +.buy-btn { + background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%); + padding: 24rpx 48rpx; + border-radius: 48rpx; + box-shadow: 0 8rpx 24rpx rgba(230, 57, 70, 0.3); +} + +.buy-btn.disabled { + opacity: 0.6; +} + +.buy-btn-text { + font-size: 30rpx; + font-weight: 600; + color: #fff; +} diff --git a/miniprogram/pages/recharge/index.js b/miniprogram/pages/recharge/index.js new file mode 100644 index 0000000..c9e060f --- /dev/null +++ b/miniprogram/pages/recharge/index.js @@ -0,0 +1,83 @@ +const app = getApp() +const payment = require('../../utils/payment') +const { request } = require('../../utils/request') + +Page({ + data: { + enterpriseId: 0, + enterpriseName: '', + amountFen: 0, + amountYuan: '0.00', + paying: false + }, + + onLoad(options) { + const rawScene = options && options.scene ? decodeURIComponent(options.scene) : '' + const sceneParams = {} + if (rawScene) { + rawScene.split('&').forEach(pair => { + const [k, v] = pair.split('=') + if (k) sceneParams[k] = v || '' + }) + } + + const enterpriseId = parseInt(sceneParams.eid || options.eid || 0, 10) || 0 + const amountFen = parseInt(sceneParams.a || options.amountFen || 0, 10) || 0 + const amountYuan = (amountFen / 100).toFixed(2) + + if (enterpriseId > 0) { + app.globalData.enterpriseIdFromScene = enterpriseId + } + + this.setData({ + enterpriseId, + enterpriseName: (app.globalData.userInfo && app.globalData.userInfo.enterpriseName) || '', + amountFen, + amountYuan + }) + + app.ensureLogin() + .then(() => { + if (enterpriseId > 0) { + request({ + url: '/api/enterprise/bind', + method: 'POST', + data: { enterpriseId }, + success: (res) => { + const payload = res && res.data && res.data.data ? res.data.data : {} + const enterpriseName = payload.enterpriseName || this.data.enterpriseName || '' + this.setData({ enterpriseName }) + }, + fail: () => {} + }) + } + }) + .catch(() => { + wx.showToast({ title: '请先登录', icon: 'none' }) + }) + }, + + submitRecharge() { + if (this.data.paying) return + if (!this.data.enterpriseId || !this.data.amountFen) { + wx.showToast({ title: '充值参数无效', icon: 'none' }) + return + } + + this.setData({ paying: true }) + payment.recharge({ + amountYuan: Number(this.data.amountYuan), + enterpriseId: this.data.enterpriseId, + success: () => { + this.setData({ paying: false }) + wx.showToast({ title: '充值成功', icon: 'success' }) + setTimeout(() => { + wx.navigateTo({ url: `/pages/enterprise/index?eid=${this.data.enterpriseId}` }) + }, 1200) + }, + fail: () => { + this.setData({ paying: false }) + } + }) + } +}) diff --git a/miniprogram/pages/recharge/index.json b/miniprogram/pages/recharge/index.json new file mode 100644 index 0000000..bbe8410 --- /dev/null +++ b/miniprogram/pages/recharge/index.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "企业余额充值" +} diff --git a/miniprogram/pages/recharge/index.wxml b/miniprogram/pages/recharge/index.wxml new file mode 100644 index 0000000..c146da3 --- /dev/null +++ b/miniprogram/pages/recharge/index.wxml @@ -0,0 +1,20 @@ + + + 企业余额充值 + 扫码后在小程序内完成支付,支付成功后自动进入企业余额 + + + 充值金额 + ¥{{amountYuan}} + + + + 企业名称 + {{enterpriseName || ('企业 #' + enterpriseId)}} + + + + + diff --git a/miniprogram/pages/recharge/index.wxss b/miniprogram/pages/recharge/index.wxss new file mode 100644 index 0000000..88ca441 --- /dev/null +++ b/miniprogram/pages/recharge/index.wxss @@ -0,0 +1,106 @@ +.recharge-page { + min-height: 100vh; + padding: 40rpx 28rpx; + background: linear-gradient(180deg, #f5f3ff 0%, #ffffff 100%); + box-sizing: border-box; +} + +.card { + background: #ffffff; + border-radius: 32rpx; + padding: 40rpx 32rpx; + box-shadow: 0 20rpx 60rpx rgba(124, 58, 237, 0.08); + border: 2rpx solid rgba(124, 58, 237, 0.06); +} + +.title { + font-size: 40rpx; + font-weight: 700; + color: #111827; +} + +.subtitle { + margin-top: 16rpx; + font-size: 26rpx; + line-height: 1.7; + color: #6b7280; +} + +.amount-box { + margin-top: 36rpx; + padding: 32rpx; + background: #f8fafc; + border-radius: 24rpx; + text-align: center; +} + +.amount-label { + display: block; + font-size: 24rpx; + color: #6b7280; +} + +.amount-value { + display: block; + margin-top: 12rpx; + font-size: 64rpx; + font-weight: 700; + color: #7c3aed; +} + +.meta-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 28rpx; + padding: 24rpx 8rpx 0; + font-size: 26rpx; + gap: 24rpx; +} + +.meta-label { + color: #6b7280; +} + +.meta-value { + color: #111827; + font-weight: 600; + max-width: 420rpx; + text-align: right; + line-height: 1.5; +} + +.pay-btn { + margin-top: 44rpx; + height: 96rpx; + line-height: 96rpx; + border-radius: 999rpx; + background: linear-gradient(135deg, #7c3aed 0%, #8b5cf6 55%, #a855f7 100%); + color: #ffffff; + font-size: 30rpx; + font-weight: 600; + box-shadow: 0 18rpx 36rpx rgba(124, 58, 237, 0.28); + border: none; + display: flex; + align-items: center; + justify-content: center; +} + +.pay-btn::after { + border: none; +} + +.pay-btn-text { + position: relative; + padding-right: 28rpx; +} + +.pay-btn-text::after { + content: '>'; + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + font-size: 24rpx; + opacity: 0.9; +} diff --git a/miniprogram/pages/result/disc.js b/miniprogram/pages/result/disc.js new file mode 100644 index 0000000..301dfb7 --- /dev/null +++ b/miniprogram/pages/result/disc.js @@ -0,0 +1,157 @@ +// pages/result/disc.js - DISC结果页(支持付费墙 + 历史详情拉取) +const app = getApp() +const payment = require('../../utils/payment') + +function toIntPercent(v) { + if (v == null) return 0 + const n = typeof v === 'number' ? v : Number(v) + return Number.isFinite(n) ? Math.round(n) : 0 +} + +function withPercentagesInt(data) { + if (!data) return data + const p = data.percentages || {} + const out = { ...data } + out.percentagesInt = { + D: toIntPercent(p.D ?? p.d), + I: toIntPercent(p.I ?? p.i), + S: toIntPercent(p.S ?? p.s), + C: toIntPercent(p.C ?? p.c) + } + return out +} + +Page({ + data: { + result: null, + typeList: [ + { type: 'D', label: 'D型 - 支配型', colorClass: 'fill-d' }, + { type: 'I', label: 'I型 - 影响型', colorClass: 'fill-i' }, + { type: 'S', label: 'S型 - 稳健型', colorClass: 'fill-s' }, + { type: 'C', label: 'C型 - 谨慎型', colorClass: 'fill-c' } + ], + payInfo: { requiresPayment: false, isPaid: false, amountYuan: 0 }, + testResultId: null, + hasReloadedAfterPay: false + }, + + onLoad(options) { + const id = options && options.id + const type = options && options.type + if (id && type === 'disc') { + this.setData({ testResultId: id }) + this.loadDetail(id) + return + } + const result = wx.getStorageSync('discResult') + if (result) { + this.setData({ result: withPercentagesInt(result) }) + this.initPayInfoFromRuntime('disc') + } else { + wx.showToast({ title: '暂无测试结果', icon: 'none' }) + setTimeout(() => wx.navigateBack(), 1500) + } + }, + + loadDetail(id) { + const apiBase = app.globalData?.apiBase || '' + const token = app.globalData?.token || wx.getStorageSync('token') || '' + if (!apiBase) { wx.showToast({ title: '配置异常', icon: 'none' }); return } + wx.showLoading({ title: '加载中...' }) + wx.request({ + url: `${apiBase}/api/test/detail`, + method: 'GET', + header: token ? { Authorization: `Bearer ${token}` } : {}, + data: { id }, + success: (res) => { + if (res.statusCode === 200 && res.data && res.data.code === 200) { + const payload = res.data.data || {} + const data = payload.data || payload + const isPaid = !!payload.isPaid + const paidAmount = payload.paidAmount != null ? Number(payload.paidAmount) : 0 + const amountYuan = payload.amountYuan != null ? Number(payload.amountYuan) : (paidAmount > 0 ? paidAmount / 100 : 0) + const needPaymentToUnlock = payload.needPaymentToUnlock === true || (!!payload.requiresPayment && !isPaid && paidAmount > 0) + this.setData({ result: withPercentagesInt(data) }) + const payInfo = { + requiresPayment: needPaymentToUnlock, + isPaid, + amountYuan: needPaymentToUnlock ? amountYuan : 0 + } + this.setData({ payInfo }) + } else { + wx.showToast({ title: res.data?.message || '加载失败', icon: 'none' }) + } + }, + fail: () => wx.showToast({ title: '网络错误', icon: 'none' }), + complete: () => wx.hideLoading() + }) + }, + + initPayInfoFromRuntime(testType) { + app.getRuntimeConfig() + .then((cfg) => { + const reportRequires = cfg.reportRequiresPayment || {} + const pricing = cfg.pricing || {} + const requiresPayment = !!(reportRequires && reportRequires[testType]) + const amountYuan = Number(pricing[testType]) || (requiresPayment ? 1.98 : 0) + this.setData({ + payInfo: { requiresPayment, isPaid: false, amountYuan } + }) + }) + .catch(() => this.setData({ payInfo: { requiresPayment: false, isPaid: false, amountYuan: 0 } })) + }, + + unlockFullReport() { + const { payInfo, testResultId, hasReloadedAfterPay } = this.data + if (!payInfo.requiresPayment || payInfo.isPaid) return + app.ensureLogin && app.ensureLogin().then((logged) => { + if (!logged) { wx.showToast({ title: '请先登录', icon: 'none' }); return } + payment.purchaseDiscTest({ + testResultId: testResultId || undefined, + success: () => { + wx.showToast({ title: '已解锁完整报告', icon: 'success' }) + this.setData({ 'payInfo.isPaid': true }) + if (testResultId && !hasReloadedAfterPay) { + this.setData({ hasReloadedAfterPay: true }) + setTimeout(() => this.loadDetail(testResultId), 500) + } + }, + fail: () => {} + }) + }) + }, + + retakeTest() { + if (!this.data.testResultId) { + wx.removeStorageSync('discResult') + } + wx.navigateTo({ url: '/pages/test/disc' }) + }, + + goHome() { + const scope = (getApp().globalData && getApp().globalData.appScope) || 'personal' + if (scope === 'enterprise') { + wx.navigateTo({ url: '/pages/enterprise/index' }) + } else { + wx.switchTab({ url: '/pages/index/index' }) + } + }, + + onShareAppMessage() { + const result = this.data.result + const { getSharePathByScope } = require('../../utils/share') + return { + title: `我的DISC类型是${result?.dominantType}型(${result?.description?.title}),来测测你的吧!`, + path: getSharePathByScope('/pages/index/index') + } + }, + + onShareTimeline() { + const result = this.data.result + const { buildShareQuery } = require('../../utils/share') + return { + title: `我的DISC类型是${result?.dominantType}型(${result?.description?.title}),来测测你的吧!`, + query: buildShareQuery() + } + } +}) diff --git a/miniprogram/pages/result/disc.json b/miniprogram/pages/result/disc.json new file mode 100644 index 0000000..adb3138 --- /dev/null +++ b/miniprogram/pages/result/disc.json @@ -0,0 +1,6 @@ +{ + "navigationBarTitleText": "DISC结果", + "navigationBarBackgroundColor": "#3B82F6", + "navigationBarTextStyle": "white", + "usingComponents": {} +} diff --git a/miniprogram/pages/result/disc.wxml b/miniprogram/pages/result/disc.wxml new file mode 100644 index 0000000..a3a7797 --- /dev/null +++ b/miniprogram/pages/result/disc.wxml @@ -0,0 +1,81 @@ + + + + + + + 您的DISC性格类型 + {{result.dominantType}}{{result.secondaryType ? ' + ' + result.secondaryType : ''}} + {{result.description.description}} + + + + + + 完整DISC报告 + • 四维得分详情 + • 性格特征与优劣势 + • 职业匹配建议 + + + + 解锁完整报告 + ¥{{payInfo.amountYuan}} / 次 + + + + + + DISC得分详情 + + + {{item.label}} + {{result.percentagesInt[item.type] != null ? result.percentagesInt[item.type] + '%' : '0%'}} + + + + + + + + + + 主要性格特征分析 + + 优势 + + + {{item}} + + + + 需要注意的方面 + + + {{item}} + + + + + + 职业匹配度分析 + + + {{item}} + + + + + + + 重新测试 + + + 返回首页 + + + + + diff --git a/miniprogram/pages/result/disc.wxss b/miniprogram/pages/result/disc.wxss new file mode 100644 index 0000000..4c17f45 --- /dev/null +++ b/miniprogram/pages/result/disc.wxss @@ -0,0 +1,197 @@ +/* pages/result/disc.wxss - 按旧版模板重构 */ +.result-page { + width: 100%; + min-height: 100vh; + background-color: #f5f5f5; +} + +.content-scroll { + width: 100%; + height: 100vh; +} + +.content-container { + padding: 32rpx; + padding-bottom: 64rpx; +} + +.type-card, +.analysis-card { + background-color: #fff; + border-radius: 24rpx; + padding: 48rpx; + margin-bottom: 32rpx; + box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08); +} + +.type-header { + text-align: center; + margin-bottom: 40rpx; +} + +.type-label { + display: block; + font-size: 32rpx; + color: #333; +} + +.type-value { + display: block; + font-size: 56rpx; + font-weight: bold; + color: #8b5cf6; + margin: 16rpx 0; +} + +.type-description { + display: block; + font-size: 28rpx; + color: #666; + line-height: 1.6; +} + +.section-title, +.card-title { + display: block; + font-size: 34rpx; + font-weight: bold; + color: #333; + margin-bottom: 20rpx; +} + +.score-item { + margin-bottom: 18rpx; +} + +.score-header { + display: flex; + justify-content: space-between; + margin-bottom: 8rpx; +} + +.score-label, +.score-value { + font-size: 28rpx; + color: #666; +} + +.progress-bar-container { + width: 100%; + height: 14rpx; + background-color: #e5e5e5; + border-radius: 8rpx; + overflow: hidden; +} + +.progress-bar { + height: 100%; + border-radius: 8rpx; +} + +.fill-d { background: linear-gradient(90deg, #ef4444 0%, #f87171 100%); } +.fill-i { background: linear-gradient(90deg, #f59e0b 0%, #fbbf24 100%); } +.fill-s { background: linear-gradient(90deg, #10b981 0%, #34d399 100%); } +.fill-c { background: linear-gradient(90deg, #3b82f6 0%, #60a5fa 100%); } + +.trait-section { + margin-bottom: 18rpx; +} + +.trait-title { + display: block; + font-size: 30rpx; + color: #333; + margin-bottom: 8rpx; +} + +.trait-item { + display: flex; + align-items: flex-start; + margin-bottom: 6rpx; +} + +.trait-bullet { + margin-right: 12rpx; + color: #8b5cf6; +} + +.trait-text { + flex: 1; + font-size: 28rpx; + color: #666; + line-height: 1.6; +} + +.career-item { + display: flex; + align-items: center; + margin-bottom: 16rpx; +} + +.career-dot { + width: 14rpx; + height: 14rpx; + background-color: #8b5cf6; + border-radius: 50%; + margin-right: 16rpx; +} + +.career-text { + flex: 1; + font-size: 28rpx; + color: #333; +} + +.action-section { + margin-top: 16rpx; + padding-bottom: 40rpx; +} + +.btn { + display: flex; + align-items: center; + justify-content: center; + width: 100% !important; + padding: 28rpx; + border-radius: 16rpx; + margin-bottom: 20rpx; + border: none; + box-sizing: border-box; + overflow: hidden; +} + +.btn::after { + border: none; +} + +.btn-primary { + background: linear-gradient(135deg, #3B82F6 0%, #60a5fa 100%); + box-shadow: 0 8rpx 24rpx rgba(59, 130, 246, 0.3); +} + +.btn-outline { + background: #fff; + border: 2rpx solid #3B82F6; +} + +.btn-text { + font-size: 32rpx; + font-weight: 600; + color: #fff; +} + +.btn-text-outline { + font-size: 30rpx; + color: #3B82F6; + font-weight: 500; +} + +.paywall-card { margin-top: 24rpx; border-radius: 24rpx; overflow: hidden; } +.paywall-content { position: relative; } +.paywall-blur { padding: 32rpx 24rpx 140rpx; border-radius: 24rpx; background: rgba(255,255,255,0.7); backdrop-filter: blur(18rpx); } +.paywall-mask { position: absolute; left: 0; top: 0; right: 0; bottom: 0; border-radius: 24rpx; background: linear-gradient(180deg, rgba(255,255,255,0.1), rgba(245,245,245,0.98)); pointer-events: none; } +.paywall-fake-title { display: block; font-size: 30rpx; font-weight: 600; color: #444; margin-bottom: 16rpx; } +.paywall-fake-line { display: block; font-size: 26rpx; color: #888; line-height: 1.8; } +.paywall-btn { position: absolute; left: 5%; right: 5%; width: 90%; bottom: 56rpx; padding: 20rpx 0; border-radius: 999rpx; background: linear-gradient(135deg, #3B82F6 0%, #60a5fa 100%); box-shadow: 0 8rpx 24rpx rgba(59,130,246,0.35); display: flex; flex-direction: row; align-items: baseline; justify-content: center; gap: 12rpx; } +.paywall-btn-main { font-size: 30rpx; color: #fff; font-weight: 600; } +.paywall-btn-price { font-size: 24rpx; color: #ffe5f0; } diff --git a/miniprogram/pages/result/mbti.js b/miniprogram/pages/result/mbti.js new file mode 100644 index 0000000..a921b35 --- /dev/null +++ b/miniprogram/pages/result/mbti.js @@ -0,0 +1,224 @@ +// pages/result/mbti.js - MBTI结果页(支持付费墙 + 历史详情拉取) +const app = getApp() +const payment = require('../../utils/payment') +const { hasPhone, bindPhoneByCode, ensureProfileCompleteAndRedirect } = require('../../utils/phoneAuth.js') + +Page({ + data: { + result: null, + dimensions: [], + mbtiDesc: { + title: '', + description: '', + strengths: [], + weaknesses: [], + careers: [], + relationships: '' + }, + payInfo: { + requiresPayment: false, + isPaid: false, + amountYuan: 0 + }, + testResultId: null, + hasReloadedAfterPay: false, + hasPhone: false + }, + + onLoad(options) { + const id = options && options.id + const type = options && options.type + + if (id && type === 'mbti') { + this.setData({ testResultId: id }) + this.loadDetail(id) + return + } + + const result = wx.getStorageSync('mbtiResult') + if (result) { + this.applyResult(result) + this.initPayInfoFromRuntime('mbti') + } else { + wx.showToast({ title: '暂无测试结果', icon: 'none' }) + setTimeout(() => wx.navigateBack(), 1500) + } + }, + + onShow() { + if (!ensureProfileCompleteAndRedirect()) return + this.setData({ hasPhone: hasPhone() }) + }, + + loadDetail(id) { + const apiBase = app.globalData?.apiBase || '' + const token = app.globalData?.token || wx.getStorageSync('token') || '' + if (!apiBase) { + wx.showToast({ title: '配置异常', icon: 'none' }) + return + } + wx.showLoading({ title: '加载中...' }) + wx.request({ + url: `${apiBase}/api/test/detail`, + method: 'GET', + header: token ? { Authorization: `Bearer ${token}` } : {}, + data: { id }, + success: (res) => { + if (res.statusCode === 200 && res.data && res.data.code === 200) { + const payload = res.data.data || {} + const data = payload.data || payload + const isPaid = !!payload.isPaid + const paidAmount = payload.paidAmount != null ? Number(payload.paidAmount) : 0 + const amountYuan = payload.amountYuan != null ? Number(payload.amountYuan) : (paidAmount > 0 ? paidAmount / 100 : 0) + const needPaymentToUnlock = payload.needPaymentToUnlock === true || (!!payload.requiresPayment && !isPaid && paidAmount > 0) + this.applyResult(data) + const payInfo = { + requiresPayment: needPaymentToUnlock, + isPaid, + amountYuan: needPaymentToUnlock ? amountYuan : 0 + } + this.setData({ payInfo }) + } else { + wx.showToast({ title: res.data?.message || '加载失败', icon: 'none' }) + } + }, + fail: () => wx.showToast({ title: '网络错误', icon: 'none' }), + complete: () => wx.hideLoading() + }) + }, + + applyResult(result) { + if (!result) return + const desc = result.description || {} + const dimensions = (result.dimensionScores && !result.locked) + ? [ + { key: 'EI', left: '外向(E)', right: '内向(I)', ...result.dimensionScores.EI }, + { key: 'SN', left: '感觉(S)', right: '直觉(N)', ...result.dimensionScores.SN }, + { key: 'TF', left: '思考(T)', right: '情感(F)', ...result.dimensionScores.TF }, + { key: 'JP', left: '判断(J)', right: '知觉(P)', ...result.dimensionScores.JP } + ] + : [] + this.setData({ + result, + dimensions, + mbtiDesc: { + title: desc.name || '', + description: desc.description || '', + strengths: desc.strengths || [], + weaknesses: desc.weaknesses || [], + careers: desc.careers || [], + relationships: desc.relationships || '' + } + }) + }, + + initPayInfoFromRuntime(testType) { + app.getRuntimeConfig() + .then((cfg) => { + const pricing = cfg.pricing || {} + const reportRequires = cfg.reportRequiresPayment || {} + const requiresPayment = !!(reportRequires && reportRequires[testType]) + const amountYuan = Number(pricing[testType]) || (requiresPayment ? 1.98 : 0) + this.setData({ + payInfo: { + requiresPayment, + isPaid: false, + amountYuan + } + }) + }) + .catch(() => { + this.setData({ + payInfo: { requiresPayment: false, isPaid: false, amountYuan: 0 } + }) + }) + }, + + unlockFullReport() { + const { payInfo, testResultId, hasReloadedAfterPay } = this.data + if (!payInfo.requiresPayment || payInfo.isPaid) return + app.ensureLogin && app.ensureLogin().then((logged) => { + if (!logged) { + wx.showToast({ title: '请先登录', icon: 'none' }) + return + } + payment.purchaseMbtiTest({ + testResultId: testResultId || undefined, + success: () => { + wx.showToast({ title: '已解锁完整报告', icon: 'success' }) + this.setData({ 'payInfo.isPaid': true }) + if (testResultId && !hasReloadedAfterPay) { + this.setData({ hasReloadedAfterPay: true }) + setTimeout(() => this.loadDetail(testResultId), 500) + } + }, + fail: () => {} + }) + }) + }, + + // 付费解锁按钮:就地触发微信手机号授权,然后调用 unlockFullReport + onGetPhoneNumberForMbtiPay(e) { + if (!ensureProfileCompleteAndRedirect()) return + const { code, errMsg } = e.detail || {} + if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) { + if (!hasPhone()) { + wx.showToast({ title: '需要授权手机号才能继续', icon: 'none' }) + return + } + this.unlockFullReport() + return + } + if (!code) { + if (hasPhone()) { + this.unlockFullReport() + } else { + wx.showToast({ title: '获取手机号失败', icon: 'none' }) + } + return + } + bindPhoneByCode(code) + .then(() => { + this.setData({ hasPhone: true }) + this.unlockFullReport() + }) + .catch(() => { + // 保持在当前页,等待用户重新点击 + }) + }, + + retakeTest() { + if (!this.data.testResultId) { + wx.removeStorageSync('mbtiResult') + } + wx.navigateTo({ url: '/pages/test/mbti' }) + }, + + goHome() { + const scope = (getApp().globalData && getApp().globalData.appScope) || 'personal' + if (scope === 'enterprise') { + wx.navigateTo({ url: '/pages/enterprise/index' }) + } else { + wx.switchTab({ url: '/pages/index/index' }) + } + }, + + onShareAppMessage() { + const result = this.data.result + const { getSharePathByScope } = require('../../utils/share') + return { + title: `我的MBTI类型是${result?.mbtiType}(${result?.description?.name}),来测测你的吧!`, + path: getSharePathByScope('/pages/index/index'), + imageUrl: '/images/share-mbti.png' + } + }, + + onShareTimeline() { + const result = this.data.result + const { buildShareQuery } = require('../../utils/share') + return { + title: `我的MBTI类型是${result?.mbtiType}(${result?.description?.name}),来测测你的吧!`, + query: buildShareQuery() + } + } +}) diff --git a/miniprogram/pages/result/mbti.json b/miniprogram/pages/result/mbti.json new file mode 100644 index 0000000..4f43927 --- /dev/null +++ b/miniprogram/pages/result/mbti.json @@ -0,0 +1,6 @@ +{ + "navigationBarTitleText": "MBTI结果", + "navigationBarBackgroundColor": "#FF6B8A", + "navigationBarTextStyle": "white", + "usingComponents": {} +} diff --git a/miniprogram/pages/result/mbti.wxml b/miniprogram/pages/result/mbti.wxml new file mode 100644 index 0000000..8c3932e --- /dev/null +++ b/miniprogram/pages/result/mbti.wxml @@ -0,0 +1,107 @@ + + + + + + + 您的MBTI性格类型 + {{result.mbtiType}} + {{mbtiDesc.title}} + {{mbtiDesc.description}} + + + + + + + 完整性格分析 + • 四维得分与主导倾向 + • 优势与需要注意的方面 + • 职业匹配与人际关系建议 + + + + + + + + + + + + + {{item.left}} + {{item.right}} + + + + + + {{item.dominant}} + {{item.percentage}}% + + + + + + + 性格特征分析 + + 优势 + + + {{item}} + + + + + 需要注意的方面 + + + {{item}} + + + + + + 职业匹配度分析 + + + {{item}} + + + + + 人际关系分析 + {{mbtiDesc.relationships}} + + + + + + 重新测试 + + + 返回首页 + + + + + diff --git a/miniprogram/pages/result/mbti.wxss b/miniprogram/pages/result/mbti.wxss new file mode 100644 index 0000000..ccbf617 --- /dev/null +++ b/miniprogram/pages/result/mbti.wxss @@ -0,0 +1,240 @@ +/* pages/result/mbti.wxss - 按旧版模板重构 */ +.result-page { + width: 100%; + min-height: 100vh; + background-color: #f5f5f5; +} + +.content-scroll { + width: 100%; + height: 100vh; +} + +.content-container { + padding: 32rpx; + padding-bottom: 64rpx; +} + +.type-card, +.analysis-card { + background-color: #fff; + border-radius: 24rpx; + padding: 48rpx; + margin-bottom: 32rpx; + box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08); +} + +.type-header { + text-align: center; + margin-bottom: 40rpx; +} + +.type-label { + display: block; + font-size: 32rpx; + color: #333; + margin-bottom: 16rpx; +} + +.type-value { + display: block; + font-size: 72rpx; + font-weight: bold; + color: #8b5cf6; + margin: 16rpx 0; +} + +.type-title { + display: block; + font-size: 34rpx; + color: #a78bfa; + margin-bottom: 20rpx; +} + +.type-description { + display: block; + font-size: 28rpx; + color: #666; + line-height: 1.6; +} + +.dimensions-section .dimension-item { + margin-bottom: 28rpx; +} + +.dimension-labels, +.dimension-values { + display: flex; + justify-content: space-between; +} + +.label-left, +.label-right, +.value-left, +.value-right { + font-size: 24rpx; + color: #666; +} + +.progress-bar-container { + width: 100%; + height: 16rpx; + background-color: #e5e5e5; + border-radius: 8rpx; + margin: 8rpx 0; + overflow: hidden; +} + +.progress-bar { + height: 100%; + border-radius: 8rpx; + background: linear-gradient(90deg, #8b5cf6 0%, #a78bfa 100%); +} + +.card-title { + display: block; + font-size: 36rpx; + font-weight: bold; + color: #333; + margin-bottom: 24rpx; +} + +.trait-section { + margin-bottom: 24rpx; +} + +.trait-title { + display: block; + font-size: 30rpx; + color: #333; + margin-bottom: 12rpx; +} + +.trait-item { + display: flex; + align-items: flex-start; + margin-bottom: 8rpx; +} + +.trait-bullet { + margin-right: 12rpx; + color: #8b5cf6; +} + +.trait-text { + flex: 1; + font-size: 28rpx; + color: #666; + line-height: 1.6; +} + +.career-item { + display: flex; + align-items: center; + margin-bottom: 18rpx; +} + +.career-dot { + width: 14rpx; + height: 14rpx; + border-radius: 50%; + background-color: #8b5cf6; + margin-right: 18rpx; +} + +.career-text { + flex: 1; + font-size: 28rpx; + color: #333; +} + +.relationship-text { + font-size: 28rpx; + color: #666; + line-height: 1.7; +} + +.action-section { + margin-top: 16rpx; + padding-bottom: 40rpx; +} + +.btn { + display: flex; + align-items: center; + justify-content: center; + width: 100% !important; + padding: 28rpx; + border-radius: 16rpx; + margin-bottom: 20rpx; + border: none; + box-sizing: border-box; + overflow: hidden; +} + +/* 消除微信 button 伪元素自带边框 */ +.btn::after { + border: none; +} + +.btn-primary { + background: linear-gradient(135deg, #FF6B8A 0%, #ff8fa3 100%); + box-shadow: 0 8rpx 24rpx rgba(255, 107, 138, 0.3); +} + +.btn-outline { + background: #fff; + border: 2rpx solid #FF6B8A; +} + +.btn-text { + font-size: 32rpx; + font-weight: 600; + color: #fff; +} + +.btn-text-outline { + font-size: 30rpx; + color: #FF6B8A; + font-weight: 500; +} + +/* 付费墙 */ +.paywall-card { + margin-top: 24rpx; + border-radius: 24rpx; + overflow: hidden; +} +.paywall-content { position: relative; } +.paywall-blur { + padding: 32rpx 24rpx 140rpx; + border-radius: 24rpx; + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(18rpx); +} +.paywall-mask { + position: absolute; + left: 0; top: 0; right: 0; bottom: 0; + border-radius: 24rpx; + background: linear-gradient(180deg, rgba(255,255,255,0.1), rgba(245,245,245,0.98)); + pointer-events: none; +} +.paywall-fake-title { display: block; font-size: 30rpx; font-weight: 600; color: #444; margin-bottom: 16rpx; } +.paywall-fake-line { display: block; font-size: 26rpx; color: #888; line-height: 1.8; } +.paywall-btn { + position: absolute; + left: 5%; right: 5%; + width: 90%; + bottom: 56rpx; + padding: 20rpx 0; + border-radius: 999rpx; + background: linear-gradient(135deg, #FF6B8A 0%, #ff8fa3 100%); + box-shadow: 0 8rpx 24rpx rgba(255, 107, 138, 0.35); + display: flex; + flex-direction: row; + align-items: baseline; + justify-content: center; + gap: 12rpx; +} +.paywall-btn-main { font-size: 30rpx; color: #fff; font-weight: 600; } +.paywall-btn-price { font-size: 24rpx; color: #ffe5f0; } diff --git a/miniprogram/pages/result/pdp.js b/miniprogram/pages/result/pdp.js new file mode 100644 index 0000000..51ae737 --- /dev/null +++ b/miniprogram/pages/result/pdp.js @@ -0,0 +1,157 @@ +// pages/result/pdp.js - PDP结果页(支持付费墙 + 历史详情拉取) +const app = getApp() +const payment = require('../../utils/payment') + +const PDP_KEYS = ['Tiger', 'Peacock', 'Koala', 'Owl', 'Chameleon'] + +function toIntPercent(v) { + if (v == null) return 0 + const n = typeof v === 'number' ? v : Number(v) + return Number.isFinite(n) ? Math.round(n) : 0 +} + +function withPercentagesInt(data) { + if (!data) return data + const p = data.percentages || {} + const out = { ...data } + const ints = {} + PDP_KEYS.forEach((k) => { ints[k] = toIntPercent(p[k] ?? p[k.toLowerCase()]) }) + out.percentagesInt = ints + return out +} + +Page({ + data: { + result: null, + typeList: [ + { type: 'Tiger', emoji: '🐅', label: '老虎型', colorClass: 'fill-tiger' }, + { type: 'Peacock', emoji: '🦚', label: '孔雀型', colorClass: 'fill-peacock' }, + { type: 'Koala', emoji: '🐨', label: '考拉型', colorClass: 'fill-koala' }, + { type: 'Owl', emoji: '🦉', label: '猫头鹰型', colorClass: 'fill-owl' }, + { type: 'Chameleon', emoji: '🦎', label: '变色龙型', colorClass: 'fill-chameleon' } + ], + payInfo: { requiresPayment: false, isPaid: false, amountYuan: 0 }, + testResultId: null, + hasReloadedAfterPay: false + }, + + onLoad(options) { + const id = options && options.id + const type = options && options.type + if (id && type === 'pdp') { + this.setData({ testResultId: id }) + this.loadDetail(id) + return + } + const result = wx.getStorageSync('pdpResult') + if (result) { + this.setData({ result: withPercentagesInt(result) }) + this.initPayInfoFromRuntime('pdp') + } else { + wx.showToast({ title: '暂无测试结果', icon: 'none' }) + setTimeout(() => wx.navigateBack(), 1500) + } + }, + + loadDetail(id) { + const apiBase = app.globalData?.apiBase || '' + const token = app.globalData?.token || wx.getStorageSync('token') || '' + if (!apiBase) { wx.showToast({ title: '配置异常', icon: 'none' }); return } + wx.showLoading({ title: '加载中...' }) + wx.request({ + url: `${apiBase}/api/test/detail`, + method: 'GET', + header: token ? { Authorization: `Bearer ${token}` } : {}, + data: { id }, + success: (res) => { + if (res.statusCode === 200 && res.data && res.data.code === 200) { + const payload = res.data.data || {} + const data = payload.data || payload + const isPaid = !!payload.isPaid + const paidAmount = payload.paidAmount != null ? Number(payload.paidAmount) : 0 + const amountYuan = payload.amountYuan != null ? Number(payload.amountYuan) : (paidAmount > 0 ? paidAmount / 100 : 0) + const needPaymentToUnlock = payload.needPaymentToUnlock === true || (!!payload.requiresPayment && !isPaid && paidAmount > 0) + this.setData({ result: withPercentagesInt(data) }) + const payInfo = { + requiresPayment: needPaymentToUnlock, + isPaid, + amountYuan: needPaymentToUnlock ? amountYuan : 0 + } + this.setData({ payInfo }) + } else { + wx.showToast({ title: res.data?.message || '加载失败', icon: 'none' }) + } + }, + fail: () => wx.showToast({ title: '网络错误', icon: 'none' }), + complete: () => wx.hideLoading() + }) + }, + + initPayInfoFromRuntime(testType) { + app.getRuntimeConfig() + .then((cfg) => { + const reportRequires = cfg.reportRequiresPayment || {} + const pricing = cfg.pricing || {} + const requiresPayment = !!(reportRequires && reportRequires[testType]) + const amountYuan = Number(pricing[testType]) || (requiresPayment ? 1.98 : 0) + this.setData({ + payInfo: { requiresPayment, isPaid: false, amountYuan } + }) + }) + .catch(() => this.setData({ payInfo: { requiresPayment: false, isPaid: false, amountYuan: 0 } })) + }, + + unlockFullReport() { + const { payInfo, testResultId, hasReloadedAfterPay } = this.data + if (!payInfo.requiresPayment || payInfo.isPaid) return + app.ensureLogin && app.ensureLogin().then((logged) => { + if (!logged) { wx.showToast({ title: '请先登录', icon: 'none' }); return } + payment.purchasePdpTest({ + testResultId: testResultId || undefined, + success: () => { + wx.showToast({ title: '已解锁完整报告', icon: 'success' }) + this.setData({ 'payInfo.isPaid': true }) + if (testResultId && !hasReloadedAfterPay) { + this.setData({ hasReloadedAfterPay: true }) + setTimeout(() => this.loadDetail(testResultId), 500) + } + }, + fail: () => {} + }) + }) + }, + + retakeTest() { + if (!this.data.testResultId) { + wx.removeStorageSync('pdpResult') + } + wx.navigateTo({ url: '/pages/test/pdp' }) + }, + + goHome() { + const scope = (getApp().globalData && getApp().globalData.appScope) || 'personal' + if (scope === 'enterprise') { + wx.navigateTo({ url: '/pages/enterprise/index' }) + } else { + wx.switchTab({ url: '/pages/index/index' }) + } + }, + + onShareAppMessage() { + const result = this.data.result + const { getSharePathByScope } = require('../../utils/share') + return { + title: `我的PDP类型是${result?.description?.type}${result?.description?.emoji},来测测你的吧!`, + path: getSharePathByScope('/pages/index/index') + } + }, + + onShareTimeline() { + const result = this.data.result + const { buildShareQuery } = require('../../utils/share') + return { + title: `我的PDP类型是${result?.description?.type}${result?.description?.emoji},来测测你的吧!`, + query: buildShareQuery() + } + } +}) diff --git a/miniprogram/pages/result/pdp.json b/miniprogram/pages/result/pdp.json new file mode 100644 index 0000000..e924a5f --- /dev/null +++ b/miniprogram/pages/result/pdp.json @@ -0,0 +1,6 @@ +{ + "navigationBarTitleText": "PDP结果", + "navigationBarBackgroundColor": "#F59E0B", + "navigationBarTextStyle": "white", + "usingComponents": {} +} diff --git a/miniprogram/pages/result/pdp.wxml b/miniprogram/pages/result/pdp.wxml new file mode 100644 index 0000000..523da7d --- /dev/null +++ b/miniprogram/pages/result/pdp.wxml @@ -0,0 +1,87 @@ + + + + + + + PDP性格类型 + {{result.description.type || result.dominantType}}{{result.description.emoji || ''}} + {{result.description.title}} + {{result.description.description}} + + + + + + 完整PDP报告 + • 五维得分详情 + • 性格特征与团队角色 + • 推荐职业 + + + + 解锁完整报告 + ¥{{payInfo.amountYuan}} / 次 + + + + + + PDP得分详情 + + + {{item.emoji}} {{item.label}} + {{result.percentagesInt && result.percentagesInt[item.type] != null ? result.percentagesInt[item.type] + '%' : '0%'}} + + + + + + + + + + 性格特征 + + 优势 + + + {{item}} + + + + 需要注意的方面 + + + {{item}} + + + + + + 团队角色 + {{result.description.teamRole}} + + + + 推荐职业 + + + {{item}} + + + + + + + 重新测试 + + + 返回首页 + + + + + diff --git a/miniprogram/pages/result/pdp.wxss b/miniprogram/pages/result/pdp.wxss new file mode 100644 index 0000000..e70c699 --- /dev/null +++ b/miniprogram/pages/result/pdp.wxss @@ -0,0 +1,211 @@ +/* pages/result/pdp.wxss - 按旧版模板重构 */ +.result-page { + width: 100%; + min-height: 100vh; + background-color: #f5f5f5; +} + +.content-scroll { + width: 100%; + height: 100vh; +} + +.content-container { + padding: 32rpx; + padding-bottom: 64rpx; +} + +.type-card, +.analysis-card { + background-color: #fff; + border-radius: 24rpx; + padding: 48rpx; + margin-bottom: 32rpx; + box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08); +} + +.type-header { + text-align: center; + margin-bottom: 40rpx; +} + +.type-label { + display: block; + font-size: 30rpx; + color: #333; +} + +.type-value { + display: block; + font-size: 56rpx; + font-weight: bold; + color: #8b5cf6; + margin: 14rpx 0; +} + +.type-title { + display: block; + font-size: 34rpx; + color: #333; + margin-bottom: 16rpx; +} + +.type-description { + display: block; + font-size: 28rpx; + color: #666; + line-height: 1.6; +} + +.section-title, +.card-title { + display: block; + font-size: 34rpx; + font-weight: bold; + color: #333; + margin-bottom: 20rpx; +} + +.score-item { + margin-bottom: 18rpx; +} + +.score-header { + display: flex; + justify-content: space-between; + margin-bottom: 8rpx; +} + +.score-label, +.score-value { + font-size: 28rpx; + color: #666; +} + +.progress-bar-container { + width: 100%; + height: 14rpx; + background-color: #e5e5e5; + border-radius: 8rpx; + overflow: hidden; +} + +.progress-bar { + height: 100%; + border-radius: 8rpx; +} + +.fill-tiger { background: #ef4444; } +.fill-peacock { background: #eab308; } +.fill-koala { background: #22c55e; } +.fill-owl { background: #3b82f6; } +.fill-chameleon { background: #a855f7; } + +.trait-section { + margin-bottom: 18rpx; +} + +.trait-title { + display: block; + font-size: 30rpx; + color: #333; + margin-bottom: 8rpx; +} + +.trait-item { + display: flex; + align-items: flex-start; + margin-bottom: 6rpx; +} + +.trait-bullet { + margin-right: 12rpx; + color: #8b5cf6; +} + +.trait-text { + flex: 1; + font-size: 28rpx; + color: #666; + line-height: 1.6; +} + +.relationship-text { + font-size: 28rpx; + color: #666; + line-height: 1.7; +} + +.career-item { + display: flex; + align-items: center; + margin-bottom: 16rpx; +} + +.career-dot { + width: 14rpx; + height: 14rpx; + background-color: #8b5cf6; + border-radius: 50%; + margin-right: 16rpx; +} + +.career-text { + flex: 1; + font-size: 28rpx; + color: #333; +} + +.action-section { + margin-top: 16rpx; + padding-bottom: 40rpx; +} + +.btn { + display: flex; + align-items: center; + justify-content: center; + width: 100% !important; + padding: 28rpx; + border-radius: 16rpx; + margin-bottom: 20rpx; + border: none; + box-sizing: border-box; + overflow: hidden; +} + +.btn::after { + border: none; +} + +.btn-primary { + background: linear-gradient(135deg, #F59E0B 0%, #fbbf24 100%); + box-shadow: 0 8rpx 24rpx rgba(245, 158, 11, 0.3); +} + +.btn-outline { + background: #fff; + border: 2rpx solid #F59E0B; +} + +.btn-text { + font-size: 32rpx; + font-weight: 600; + color: #fff; +} + +.btn-text-outline { + font-size: 30rpx; + color: #F59E0B; + font-weight: 500; +} + +.paywall-card { margin-top: 24rpx; border-radius: 24rpx; overflow: hidden; } +.paywall-content { position: relative; } +.paywall-blur { padding: 32rpx 24rpx 140rpx; border-radius: 24rpx; background: rgba(255,255,255,0.7); backdrop-filter: blur(18rpx); } +.paywall-mask { position: absolute; left: 0; top: 0; right: 0; bottom: 0; border-radius: 24rpx; background: linear-gradient(180deg, rgba(255,255,255,0.1), rgba(245,245,245,0.98)); pointer-events: none; } +.paywall-fake-title { display: block; font-size: 30rpx; font-weight: 600; color: #444; margin-bottom: 16rpx; } +.paywall-fake-line { display: block; font-size: 26rpx; color: #888; line-height: 1.8; } +.paywall-btn { position: absolute; left: 5%; right: 5%; width: 90%; bottom: 56rpx; padding: 20rpx 0; border-radius: 999rpx; background: linear-gradient(135deg, #F59E0B 0%, #fbbf24 100%); box-shadow: 0 8rpx 24rpx rgba(245,158,11,0.35); display: flex; flex-direction: row; align-items: baseline; justify-content: center; gap: 12rpx; } +.paywall-btn-main { font-size: 30rpx; color: #fff; font-weight: 600; } +.paywall-btn-price { font-size: 24rpx; color: #ffe5f0; } diff --git a/miniprogram/pages/result/resume.js b/miniprogram/pages/result/resume.js new file mode 100644 index 0000000..bb18907 --- /dev/null +++ b/miniprogram/pages/result/resume.js @@ -0,0 +1,524 @@ +// pages/result/resume.js - 简历综合分析结果页 +const app = getApp() +const payment = require('../../utils/payment') + +Page({ + data: { + loading: true, + error: '', + content: '', + sections: [], + resumeData: null, + fileUrl: '', + progress: 0, + analyzingTip: '正在准备分析数据...', + analyzingTitle: '综合分析生成中', + // 支付相关 + payInfo: { + requiresPayment: false, + isPaid: false, + amountYuan: 0 + }, + testResultId: 0, + paying: false + }, + + onLoad(options) { + const fileUrl = options.fileUrl ? decodeURIComponent(options.fileUrl) : '' + this.setData({ fileUrl }) + + // 从历史记录进入:直接读已存的数据,不重新生成 + if (options.id && options.type === 'resume') { + this.loadFromHistory(options.id) + } else { + this.fetchResumeAnalysis() + } + }, + + loadFromHistory(id) { + this.setData({ loading: true, error: '', content: '', sections: [], resumeData: null }) + const apiBase = (app.globalData && app.globalData.apiBase) || wx.getStorageSync('apiBase') || '' + const token = (app.globalData && app.globalData.token) || wx.getStorageSync('token') || '' + wx.request({ + url: `${apiBase}/api/test/detail?id=${id}`, + method: 'GET', + timeout: 15000, + header: { 'Authorization': token ? `Bearer ${token}` : '' }, + success: (res) => { + if (res.statusCode === 200 && res.data && res.data.data) { + const payload = res.data.data + let data = payload.data || payload + + // 兼容:历史数据可能是字符串,或包在 content 里的 JSON 字符串 + data = this.normalizeStructuredData(data) + + const structured = !!data && data._structured === true + + let content = '' + let sections = [] + + if (structured) { + const built = this.buildSectionsFromStructured(data) + content = built.content + sections = built.sections + } else { + const raw = data.content || '' + content = raw + sections = this.parseContent(raw) + } + const requiresPayment = !!payload.requiresPayment + const isPaid = !!payload.isPaid + const amountYuan = payload.paidAmount ? payload.paidAmount / 100 : 0 + this.setData({ + loading: false, + testResultId: payload.id || parseInt(id) || 0, + content: (!requiresPayment || isPaid) ? content : '', + sections: (!requiresPayment || isPaid) ? sections : [], + resumeData: structured ? data : null, + payInfo: { requiresPayment: requiresPayment && !isPaid, isPaid, amountYuan } + }) + } else { + this.setData({ loading: false, error: '加载历史记录失败,请返回重试。' }) + } + }, + fail: () => { + this.setData({ loading: false, error: '网络请求失败,请检查网络后重试。' }) + } + }) + }, + + fetchResumeAnalysis() { + this.setData({ loading: true, error: '', content: '', sections: [], resumeData: null, progress: 0 }) + + const tips = [ + '正在汇总各项测评结果...', + '正在解析简历内容...', + 'AI 正在进行深度匹配分析...', + '正在生成综合评估报告...', + '分析即将完成,请稍候...' + ] + let progress = 0 + let tipIndex = 0 + + // 进度动画 + const timer = setInterval(() => { + progress += 2 + if (progress > 98) progress = 98 + if (progress > (tipIndex + 1) * 18 && tipIndex < tips.length - 1) tipIndex++ + this.setData({ progress: Math.floor(progress), analyzingTip: tips[tipIndex] }) + }, 200) + + const apiBase = (app.globalData && app.globalData.apiBase) || wx.getStorageSync('apiBase') || '' + const token = (app.globalData && app.globalData.token) || wx.getStorageSync('token') || '' + + // 获取 enterpriseId + const gd = app.globalData || {} + const storedUser = wx.getStorageSync('userInfo') || null + const enterpriseId = gd.enterpriseIdFromScene + || (gd.userInfo && gd.userInfo.enterpriseId) + || (storedUser && storedUser.enterpriseId) + || null + + const postData = {} + if (this.data.fileUrl) postData.fileUrl = this.data.fileUrl + if (enterpriseId) postData.enterpriseId = enterpriseId + + wx.request({ + url: `${apiBase}/api/resume/analyze`, + method: 'POST', + timeout: 120000, + header: { + 'Content-Type': 'application/json', + 'Authorization': token ? `Bearer ${token}` : '' + }, + data: postData, + success: (res) => { + clearInterval(timer) + if (res.statusCode === 200 && res.data) { + if (res.data.code === 200) { + let d = res.data.data || {} + const p = d._payment || {} + const requiresPayment = !!p.requiresPayment + const amountYuan = p.amountYuan || 0 + const resultId = d._testResultId || 0 + + // 兼容:API 直接返回结构化对象,或把 JSON 放在 content 里 + d = this.normalizeStructuredData(d) + + const structured = !!d && d._structured === true + + let content = '' + let sections = [] + + if (structured) { + const built = this.buildSectionsFromStructured(d) + content = built.content + sections = built.sections + } else { + const raw = d.content || '' + content = raw || '分析已完成,但未返回可展示的内容。' + sections = this.parseContent(raw) + } + + this.setData({ + loading: false, + progress: 100, + testResultId: resultId, + content: requiresPayment ? '' : content, + sections: requiresPayment ? [] : sections, + resumeData: structured ? d : null, + payInfo: { requiresPayment, isPaid: !requiresPayment, amountYuan } + }) + } else { + this.setData({ + loading: false, + error: res.data.message || res.data.msg || '综合分析失败,请稍后重试。' + }) + } + } else { + this.setData({ + loading: false, + error: '服务器返回异常,请稍后重试。' + }) + } + }, + fail: (err) => { + clearInterval(timer) + const isTimeout = err && (err.errMsg || '').indexOf('timeout') !== -1 + this.setData({ + loading: false, + error: isTimeout ? 'AI 分析耗时较长,请点击「重新生成」再试。' : '网络请求失败,请检查网络后重试。' + }) + } + }) + }, + + // 支付解锁 + doPay() { + if (this.data.paying) return + this.setData({ paying: true }) + const testResultId = this.data.testResultId || 0 + payment.purchaseResumeAnalysis({ + testResultId: testResultId > 0 ? testResultId : undefined, + success: () => { + this.setData({ paying: false, 'payInfo.requiresPayment': false, 'payInfo.isPaid': true }) + // 支付成功后重新拉取完整内容 + if (testResultId > 0) { + this.loadFromHistory(testResultId) + } + }, + fail: (err) => { + this.setData({ paying: false }) + wx.showToast({ title: (err && err.message) || '支付失败,请重试', icon: 'none' }) + } + }) + }, + + goHome() { + const scope = (app.globalData && app.globalData.appScope) || 'personal' + if (scope === 'enterprise') { + wx.navigateTo({ url: '/pages/enterprise/index' }) + } else { + wx.switchTab({ url: '/pages/index/index' }) + } + }, + + onShareAppMessage() { + return { + title: '我刚刚完成了一份人才简历综合分析报告,快来看看吧!', + path: '/pages/index/index' + } + }, + + onPullDownRefresh() { + this.fetchResumeAnalysis() + wx.stopPullDownRefresh() + }, + + // 智能解析 AI 输出的长文本,按标题分块 + parseContent(content) { + if (!content) return []; + + // 1. 预处理:去掉首尾空行 + content = content.trim(); + + // 2. 尝试分割 + // 使用正则匹配头部标记:## Title 或 ### Title 或 **一、Title** 或 一、**Title** + const parts = content.split(/\n(?=#{2,4}\s+|\*\*?[\d一二三四五六七八九十]+[、\.].*?\*\*?|【.*?】)/); + const sections = []; + + parts.forEach(part => { + let title = ''; + let body = ''; + + // 匹配 ## 标题 + const hMatch = part.match(/^(?:#{2,4}\s+)(.*?)\n([\s\S]*)$/m); + // 匹配 **一、标题** 或 一、**标题** + const bMatch = part.match(/^(?:\*\*?[\d一二三四五六七八九十]+[、\.].*?\*\*?)([\s\S]*)$/m); + // 匹配 【标题】 + const kMatch = part.match(/^(?:【(.*?)】)\n?([\s\S]*)$/m); + + if (hMatch) { + title = hMatch[1].replace(/[\*#]/g, '').trim(); + body = hMatch[2].trim(); + } else if (kMatch) { + title = kMatch[1].trim(); + body = kMatch[2].trim(); + } else if (bMatch) { + const rawTitleMatch = part.match(/^(\*\*?.*?\*\*?)/); + title = rawTitleMatch ? rawTitleMatch[1].replace(/[\*#]/g, '').trim() : ''; + body = bMatch[1].trim(); + } else { + body = part.trim(); + } + + // 清洗正文中的 Markdown 符号 + body = this.cleanMarkdown(body); + + if (title || body) { + sections.push({ + title: title || '报告详情', + icon: this.getSectionIcon(title), + body: body + }); + } + }); + + return sections; + }, + + // 兼容各种后端返回形态,统一整理为结构化对象或普通文本 + normalizeStructuredData(raw) { + let data = raw + + // 字符串:尝试直接当 JSON 解析 + if (typeof data === 'string') { + try { + const parsed = JSON.parse(data) + if (parsed && typeof parsed === 'object') { + data = parsed + } + } catch (e) {} + } + + // 包在 content 里的 JSON 字符串 + if (data && typeof data === 'object' && typeof data.content === 'string') { + const text = data.content.trim() + if (text.startsWith('{')) { + try { + const parsedInner = JSON.parse(text) + if (parsedInner && typeof parsedInner === 'object') { + // 把外层 fileUrl 等透传下去 + if (data.fileUrl && !parsedInner.fileUrl) { + parsedInner.fileUrl = data.fileUrl + } + data = parsedInner + } + } catch (e) {} + } + } + + // 标记是否为结构化 JSON + if ( + data && + typeof data === 'object' && + (data.version === 2 || + !!data.overview || + !!data.portrait || + !!data.hrView || + !!data.bossView) + ) { + data._structured = true + } + + return data + }, + + // 从结构化 JSON 构建前端展示区块 + buildSectionsFromStructured(data) { + const sections = [] + + if (data.overview) { + sections.push({ + title: '整体人才画像', + icon: '👤', + body: this.cleanMarkdown(data.overview) + }) + } + + if (data.resumeHighlights) { + sections.push({ + title: '简历要点', + icon: '📄', + body: this.cleanMarkdown(data.resumeHighlights) + }) + } + + const portrait = data.portrait || {} + if ( + (portrait.coreStrengths && portrait.coreStrengths.length) || + (portrait.coreRisks && portrait.coreRisks.length) || + portrait.workStyle + ) { + const lines = [] + if (portrait.coreStrengths && portrait.coreStrengths.length) { + lines.push('【核心优势】') + lines.push(...portrait.coreStrengths.map((s) => '- ' + s)) + } + if (portrait.coreRisks && portrait.coreRisks.length) { + lines.push('') + lines.push('【潜在风险】') + lines.push(...portrait.coreRisks.map((s) => '- ' + s)) + } + if (portrait.workStyle) { + lines.push('') + lines.push('【工作风格】') + lines.push(portrait.workStyle) + } + sections.push({ + title: '人才画像', + icon: '🌈', + body: this.cleanMarkdown(lines.join('\n')) + }) + } + + const hrView = data.hrView || {} + const role = hrView.roleRecommend || {} + if ((role.bestFit && role.bestFit.length) || (role.notSuitable && role.notSuitable.length)) { + const lines = [] + if (role.bestFit && role.bestFit.length) { + lines.push('【推荐岗位】') + lines.push(...role.bestFit.map((s) => '- ' + s)) + } + if (role.notSuitable && role.notSuitable.length) { + lines.push('') + lines.push('【不适合场景】') + lines.push(...role.notSuitable.map((s) => '- ' + s)) + } + sections.push({ + title: '岗位匹配建议(HR视角)', + icon: '🧭', + body: this.cleanMarkdown(lines.join('\n')) + }) + } + + if (hrView.lifecycle) { + const lc = hrView.lifecycle + const parts = [] + if (lc.onboarding) parts.push('【入职】' + lc.onboarding) + if (lc.probation) parts.push('【试用期】' + lc.probation) + if (lc.growth) parts.push('【成长】' + lc.growth) + if (lc.retention) parts.push('【留存】' + lc.retention) + if (parts.length) { + sections.push({ + title: '员工全生命周期预测', + icon: '📈', + body: this.cleanMarkdown(parts.join('\n\n')) + }) + } + } + + if (hrView.performance || hrView.teamFit || hrView.complianceRisk) { + const lines = [] + if (hrView.performance) { + lines.push('【绩效潜力】' + (hrView.performance.potential || '')) + if (hrView.performance.drivers && hrView.performance.drivers.length) { + lines.push('驱动因素:' + hrView.performance.drivers.join(';')) + } + if (hrView.performance.risks && hrView.performance.risks.length) { + lines.push('风险提示:' + hrView.performance.risks.join(';')) + } + lines.push('') + } + if (hrView.teamFit) { + if (hrView.teamFit.bestTeam) { + lines.push('【适配团队】' + hrView.teamFit.bestTeam) + } + if (hrView.teamFit.manageAdvice) { + lines.push('【管理建议】' + hrView.teamFit.manageAdvice) + } + lines.push('') + } + if (hrView.complianceRisk) { + const level = hrView.complianceRisk.level || '' + const notes = hrView.complianceRisk.notes || '' + lines.push('【合规风险】' + level + (notes ? ';' + notes : '')) + } + if (lines.length) { + sections.push({ + title: '绩效 · 团队 · 合规', + icon: '📊', + body: this.cleanMarkdown(lines.join('\n')) + }) + } + } + + const bossView = data.bossView || {} + if (bossView.headline || (bossView.metrics && bossView.metrics.length) || bossView.costInsight) { + const lines = [] + if (bossView.headline) { + lines.push('【一句话结论】' + bossView.headline) + } + if (bossView.metrics && bossView.metrics.length) { + lines.push('') + lines.push('【关键指标】') + bossView.metrics.forEach((m) => { + if (!m) return + lines.push(`- ${m.label}:${m.value || ''}`) + }) + } + if (bossView.costInsight) { + lines.push('') + lines.push('【用人成本与产出】' + bossView.costInsight) + } + sections.push({ + title: '给老板看的摘要', + icon: '💼', + body: this.cleanMarkdown(lines.join('\n')) + }) + } + + const mainContent = + data.overview || + data.resumeHighlights || + (data.portrait && data.portrait.workStyle) || + '' + + return { + content: this.cleanMarkdown(mainContent), + sections + } + }, + + // 清洗正文中的 Markdown 符号 + cleanMarkdown(text) { + if (!text) return ''; + return text + .replace(/\*\*\*(.+?)\*\*\*/g, '$1') // ***bold italic*** + .replace(/\*\*(.+?)\*\*/g, '$1') // **bold** + .replace(/\*(.+?)\*/g, '$1') // *italic* + .replace(/^#{1,6}\s+/gm, '') // ## 标题符号 + .replace(/^[-*]\s+/gm, '• ') // - 无序列表 → 圆点 + .replace(/^\d+\.\s+/gm, (m) => m) // 保留有序列表编号 + .replace(/`(.+?)`/g, '$1') // `code` + .replace(/\[(.+?)\]\(.+?\)/g, '$1') // [链接](url) → 链接文字 + .trim(); + }, + + // 根据标题关键词匹配图标 + getSectionIcon(title) { + if (!title) return '📝'; + const map = { + '评估': '📊', '综述': '📊', '评价': '📊', '总评': '📊', + '优势': '🌟', '特质': '🌟', '性格': '🌟', + '岗位': '🎯', '适配': '🎯', '匹配': '🎯', '核心': '🎯', + '职业': '💼', '发展': '💼', '建议': '💡', '提升': '💡', + '面试': '🗣️', '录用': '✅', '风险': '⚠️' + }; + for (let key in map) { + if (title.indexOf(key) !== -1) return map[key]; + } + return '📝'; + } +}) + diff --git a/miniprogram/pages/result/resume.json b/miniprogram/pages/result/resume.json new file mode 100644 index 0000000..de5d9c8 --- /dev/null +++ b/miniprogram/pages/result/resume.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "简历综合分析" +} + diff --git a/miniprogram/pages/result/resume.wxml b/miniprogram/pages/result/resume.wxml new file mode 100644 index 0000000..316f1f1 --- /dev/null +++ b/miniprogram/pages/result/resume.wxml @@ -0,0 +1,86 @@ + + + + + + + + + {{analyzingTitle || '正在分析中'}} + {{analyzingTip}} + + + + 这个过程可能需要30秒到1分钟,请耐心等待... + + + + + + + + + {{error ? '⚠️' : '📋'}} + + {{error ? '生成失败' : '人才综合评估报告'}} + {{error ? '请检查后重新生成' : '基于测评结果 · AI 综合分析'}} + + + + + + 失败原因 + {{error}} + + 点击重新生成 + + + + + + 🔒 + 报告已生成,需付费解锁 + 完整的简历综合分析报告已生成\n支付后即可查看全部内容 + + 解锁价格 + ¥{{payInfo.amountYuan}} + + + {{paying ? '支付中...' : '立即支付解锁'}} + + + + + + + + {{item.icon}} + {{item.title}} + + + {{item.body}} + + + + + + + 综合分析综评 + {{content}} + + + + + + + 重新分析生成 + + + 返回首页 + + + + + diff --git a/miniprogram/pages/result/resume.wxss b/miniprogram/pages/result/resume.wxss new file mode 100644 index 0000000..d7ebde7 --- /dev/null +++ b/miniprogram/pages/result/resume.wxss @@ -0,0 +1,348 @@ +/* pages/result/resume.wxss - 简历综合分析结果页 */ +.container { + min-height: 100vh; + background-color: #f5f5f5; + padding: 0 24rpx 60rpx; +} + +/* ========== 分析中模态框 ========== */ +.analyzing-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} + +.analyzing-content { + background-color: #fff; + border-radius: 24rpx; + padding: 80rpx 60rpx; + width: 560rpx; + display: flex; + flex-direction: column; + align-items: center; + box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.15); +} + +.analyzing-icon { + width: 120rpx; + height: 120rpx; + margin-bottom: 40rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.spinner { + width: 120rpx; + height: 120rpx; + border: 8rpx solid #e0e0e0; + border-top-color: #e63946; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.analyzing-title { + font-size: 36rpx; + font-weight: 600; + color: #333; + margin-bottom: 16rpx; +} + +.analyzing-desc { + font-size: 28rpx; + color: #e63946; + margin-bottom: 24rpx; + text-align: center; + font-weight: 500; +} + +.analyzing-bar { + width: 400rpx; + height: 12rpx; + background: #e5e5e5; + border-radius: 6rpx; + overflow: hidden; + margin-bottom: 24rpx; +} + +.analyzing-bar-fill { + height: 100%; + background: linear-gradient(90deg, #e63946, #ff6b9d); + border-radius: 6rpx; + transition: width 0.3s ease; +} + +.analyzing-hint { + font-size: 24rpx; + color: #999; + text-align: center; + line-height: 1.6; +} + +/* ========== 顶部 Hero 区域 ========== */ +.hero-banner { + display: flex; + align-items: center; + border-radius: 20rpx; + padding: 32rpx 28rpx; + margin: 24rpx 0; + gap: 20rpx; +} + +.hero-success { + background: linear-gradient(135deg, #1a1a2e 0%, #3a3a5c 100%); +} + +.hero-error { + background: linear-gradient(135deg, #c62828 0%, #e53935 100%); +} + +.hero-icon { + font-size: 64rpx; + line-height: 1; + flex-shrink: 0; +} + +.hero-text-wrap { + display: flex; + flex-direction: column; + gap: 8rpx; +} + +.hero-title { + font-size: 34rpx; + font-weight: 700; + color: #fff; + display: block; +} + +.hero-sub { + font-size: 24rpx; + color: rgba(255, 255, 255, 0.65); + display: block; +} + +/* ========== 通用卡片 ========== */ +.card { + background-color: #fff; + border-radius: 16rpx; + padding: 40rpx 32rpx; + margin-bottom: 24rpx; + box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05); + word-wrap: break-word; +} + +.card-title { + font-size: 34rpx; + font-weight: 700; + color: #1a1a2e; + display: block; + margin-bottom: 24rpx; + border-left: 8rpx solid #e63946; + padding-left: 20rpx; +} + +.card-text { + font-size: 28rpx; + color: #333; + line-height: 1.8; + display: block; + white-space: pre-wrap; +} + +.error-title { + color: #f44336; + border-left-color: #f44336; +} + +.error-message { + color: #666; + background-color: #f9f9f9; + padding: 20rpx; + border-radius: 12rpx; +} + +.action-btn-retry { + margin-top: 32rpx; + background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%); + height: 88rpx; + border-radius: 44rpx; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 6rpx 16rpx rgba(230, 57, 70, 0.25); +} + +.retry-text { + font-size: 30rpx; + color: #fff; + font-weight: 600; +} + +/* ========== 分块卡片 ========== */ +.section-card { + padding: 0; + overflow: hidden; +} + +.section-header { + display: flex; + align-items: center; + padding: 28rpx 32rpx 20rpx; + background: linear-gradient(135deg, #fff5f5 0%, #fff 100%); + border-bottom: 1rpx solid #f5f5f5; +} + +.section-icon { + font-size: 40rpx; + margin-right: 16rpx; + line-height: 1; +} + +.section-title-text { + font-size: 30rpx; + font-weight: 700; + color: #1a1a2e; + flex: 1; + display: block; +} + +.section-body { + padding: 24rpx 32rpx 32rpx; +} + +.section-body .content-text { + font-size: 27rpx; + color: #444; + line-height: 1.9; + letter-spacing: 0.5rpx; +} + +/* ========== 操作按钮 ========== */ +.action-section { + margin-top: 16rpx; + padding-bottom: 40rpx; +} + +.btn { + display: flex; + align-items: center; + justify-content: center; + width: 100%!important; + height: 100rpx; + min-height: 100rpx; + border-radius: 28rpx; + margin-bottom: 20rpx; + padding: 0; + border: none; + line-height: 100rpx; + box-sizing: border-box; + overflow: hidden; +} + +.btn::after { + border: none; +} + +.btn-primary { + background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%); + box-shadow: 0 8rpx 24rpx rgba(230, 57, 70, 0.3); +} + +.btn-outline { + background: #fff; + border: 2rpx solid #e63946; +} + +.btn-text { + font-size: 32rpx; + font-weight: 600; + color: #fff; +} + +.btn-text-outline { + font-size: 30rpx; + color: #e63946; + font-weight: 500; +} + +/* ========== 支付解锁卡片 ========== */ +.pay-gate-card { + display: flex; + flex-direction: column; + align-items: center; + padding: 60rpx 40rpx 48rpx; + text-align: center; +} + +.pay-gate-icon { + font-size: 80rpx; + margin-bottom: 24rpx; +} + +.pay-gate-title { + font-size: 36rpx; + font-weight: 700; + color: #1f2937; + margin-bottom: 16rpx; +} + +.pay-gate-desc { + font-size: 28rpx; + color: #6b7280; + line-height: 1.6; + margin-bottom: 36rpx; +} + +.pay-gate-amount { + display: flex; + align-items: center; + gap: 16rpx; + background: #fff5f5; + border-radius: 20rpx; + padding: 20rpx 40rpx; + margin-bottom: 40rpx; +} + +.pay-amount-label { + font-size: 26rpx; + color: #6b7280; +} + +.pay-amount-value { + font-size: 52rpx; + font-weight: 800; + color: #e63946; +} + +.pay-gate-btn { + width: 100%; + height: 96rpx; + background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%); + border-radius: 48rpx; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 8rpx 24rpx rgba(230, 57, 70, 0.35); +} + +.pay-gate-btn.paying { + opacity: 0.7; +} + +.pay-gate-btn-text { + font-size: 32rpx; + font-weight: 700; + color: #fff; +} diff --git a/miniprogram/pages/test-select/index.js b/miniprogram/pages/test-select/index.js new file mode 100644 index 0000000..90002dd --- /dev/null +++ b/miniprogram/pages/test-select/index.js @@ -0,0 +1,21 @@ +// pages/test-select/index.js - 人脸测试后的详情性格测试入口(MBTI / PDP / DISC) +Page({ + data: {}, + + onLoad() {}, + + // 进入 MBTI 测试 + goMBTI() { + wx.navigateTo({ url: '/pages/test/mbti' }) + }, + + // 进入 PDP 测试 + goPDP() { + wx.navigateTo({ url: '/pages/test/pdp' }) + }, + + // 进入 DISC 测试 + goDISC() { + wx.navigateTo({ url: '/pages/test/disc' }) + } +}) diff --git a/miniprogram/pages/test-select/index.json b/miniprogram/pages/test-select/index.json new file mode 100644 index 0000000..e4c0c1e --- /dev/null +++ b/miniprogram/pages/test-select/index.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "详细性格测试", + "navigationBarBackgroundColor": "#FFFFFF", + "usingComponents": {} +} diff --git a/miniprogram/pages/test-select/index.wxml b/miniprogram/pages/test-select/index.wxml new file mode 100644 index 0000000..d35442c --- /dev/null +++ b/miniprogram/pages/test-select/index.wxml @@ -0,0 +1,40 @@ + + + + 选择一项详细性格测试 + 完成问卷即可获得对应维度的专业报告,与面相分析结果互补 + + + + + 🧠 + + + MBTI 性格测试 + 16型人格 · 发现你的认知与决策风格 + + + + + + + 🦁 + + + PDP 行为偏好测试 + 老虎 / 孔雀 / 考拉 / 猫头鹰 · 行为风格 + + + + + + + 📊 + + + DISC 性格测试 + D/I/S/C 四维 · 沟通与行为倾向 + + + + diff --git a/miniprogram/pages/test-select/index.wxss b/miniprogram/pages/test-select/index.wxss new file mode 100644 index 0000000..9b85d95 --- /dev/null +++ b/miniprogram/pages/test-select/index.wxss @@ -0,0 +1,107 @@ +/* pages/test-select/index.wxss - 详细性格测试入口,与现有 UI 风格一致 */ +.container { + min-height: 100vh; + background: linear-gradient(180deg, #fff5f5 0%, #f5f5f5 50%, #ffffff 100%); + padding: 24rpx 24rpx 60rpx; + box-sizing: border-box; +} + +.card { + background-color: #fff; + border-radius: 16rpx; + padding: 32rpx; + margin-bottom: 24rpx; + box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04); +} + +/* 顶部说明 */ +.intro-card { + text-align: center; + padding: 40rpx 32rpx; +} + +.intro-title { + display: block; + font-size: 34rpx; + font-weight: 700; + color: #e63946; + margin-bottom: 16rpx; +} + +.intro-desc { + display: block; + font-size: 26rpx; + color: #666; + line-height: 1.6; +} + +/* 入口卡片(与 result 页 next-btn 同色系) */ +.entry-card { + display: flex; + align-items: center; + padding: 28rpx 32rpx; + transition: opacity 0.2s; + border-radius: 16rpx; + border: 2rpx solid transparent; +} + +.entry-card:active { + opacity: 0.9; + background: linear-gradient(135deg, rgba(230, 57, 70, 0.04) 0%, rgba(255, 107, 157, 0.04) 100%); + border-color: rgba(230, 57, 70, 0.15); +} + +.entry-icon-wrap { + width: 88rpx; + height: 88rpx; + border-radius: 20rpx; + display: flex; + align-items: center; + justify-content: center; + margin-right: 24rpx; + flex-shrink: 0; +} + +.entry-icon-wrap.mbti { + background: linear-gradient(135deg, rgba(124, 58, 237, 0.15) 0%, rgba(139, 92, 246, 0.2) 100%); +} + +.entry-icon-wrap.pdp { + background: linear-gradient(135deg, rgba(230, 57, 70, 0.12) 0%, rgba(255, 107, 157, 0.15) 100%); +} + +.entry-icon-wrap.disc { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(96, 165, 250, 0.18) 100%); +} + +.entry-emoji { + font-size: 44rpx; +} + +.entry-content { + flex: 1; + min-width: 0; +} + +.entry-name { + display: block; + font-size: 30rpx; + font-weight: 600; + color: #333; + margin-bottom: 8rpx; +} + +.entry-brief { + display: block; + font-size: 24rpx; + color: #888; + line-height: 1.4; +} + +.entry-arrow { + font-size: 32rpx; + color: #e63946; + font-weight: 600; + margin-left: 16rpx; + flex-shrink: 0; +} diff --git a/miniprogram/pages/test/disc.js b/miniprogram/pages/test/disc.js new file mode 100644 index 0000000..95863a5 --- /dev/null +++ b/miniprogram/pages/test/disc.js @@ -0,0 +1,134 @@ +// pages/test/disc.js +const { discQuestions, shuffleQuestions } = require('../../utils/questions') +const { discDescriptions } = require('../../utils/descriptions') +const app = getApp() + +Page({ + data: { + questions: [], + currentIndex: 0, + currentQuestion: null, + answers: {}, + selectedAnswer: null, + total: discQuestions.length, + answeredCount: 0, + progress: 0, + timeRemaining: 15 * 60, + formatTime: '15:00', + isSubmitting: false + }, + + timer: null, + + onLoad() { + const questions = shuffleQuestions(discQuestions) + this.setData({ questions, currentQuestion: questions[0] }) + this.startTimer() + }, + + onUnload() { + if (this.timer) clearInterval(this.timer) + }, + + startTimer() { + this.timer = setInterval(() => { + let time = this.data.timeRemaining - 1 + if (time <= 0) { + clearInterval(this.timer) + this.submitTest() + return + } + const minutes = Math.floor(time / 60) + const seconds = time % 60 + this.setData({ + timeRemaining: time, + formatTime: `${minutes}:${seconds < 10 ? '0' : ''}${seconds}` + }) + }, 1000) + }, + + selectAnswer(e) { + const value = e.currentTarget.dataset.value + const questionId = this.data.currentQuestion.id + let answers = { ...this.data.answers } + answers[questionId] = value + + this.setData({ + selectedAnswer: value, + answers: answers, + answeredCount: Object.keys(answers).length, + progress: (Object.keys(answers).length / this.data.total) * 100 + }) + + setTimeout(() => { + if (this.data.currentIndex < this.data.total - 1) { + this.nextQuestion() + } + }, 300) + }, + + prevQuestion() { + if (this.data.currentIndex > 0) { + const newIndex = this.data.currentIndex - 1 + const newQuestion = this.data.questions[newIndex] + this.setData({ + currentIndex: newIndex, + currentQuestion: newQuestion, + selectedAnswer: this.data.answers[newQuestion.id] || null + }) + } + }, + + nextQuestion() { + if (this.data.currentIndex < this.data.total - 1) { + const newIndex = this.data.currentIndex + 1 + const newQuestion = this.data.questions[newIndex] + this.setData({ + currentIndex: newIndex, + currentQuestion: newQuestion, + selectedAnswer: this.data.answers[newQuestion.id] || null + }) + } + }, + + submitTest() { + if (this.data.isSubmitting) return + this.setData({ isSubmitting: true }) + + const scores = { D: 0, I: 0, S: 0, C: 0 } + Object.values(this.data.answers).forEach(value => { + if (scores.hasOwnProperty(value)) scores[value]++ + }) + + const total = Object.values(scores).reduce((sum, v) => sum + v, 0) + const percentages = { + D: Math.round((scores.D / total) * 100), + I: Math.round((scores.I / total) * 100), + S: Math.round((scores.S / total) * 100), + C: Math.round((scores.C / total) * 100) + } + + const dominantType = Object.entries(scores).sort((a, b) => b[1] - a[1])[0][0] + const secondaryType = Object.entries(scores).sort((a, b) => b[1] - a[1])[1][0] + + const resultData = { + scores, + percentages, + dominantType, + secondaryType, + description: discDescriptions[dominantType], + testDuration: 15 * 60 - this.data.timeRemaining, + completedAt: new Date().toISOString(), + // 便于后端留存完整答题过程 + answers: this.data.answers + } + + // 本地缓存 + 全局缓存 + wx.setStorageSync('discResult', resultData) + if (app && typeof app.saveTestResult === 'function') { + app.saveTestResult('disc', resultData) + } + + wx.redirectTo({ url: '/pages/result/disc' }) + } +}) diff --git a/miniprogram/pages/test/disc.json b/miniprogram/pages/test/disc.json new file mode 100644 index 0000000..b7c83cd --- /dev/null +++ b/miniprogram/pages/test/disc.json @@ -0,0 +1,6 @@ +{ + "navigationBarTitleText": "DISC测试", + "navigationBarBackgroundColor": "#3B82F6", + "navigationBarTextStyle": "white", + "usingComponents": {} +} diff --git a/miniprogram/pages/test/disc.wxml b/miniprogram/pages/test/disc.wxml new file mode 100644 index 0000000..a83cc11 --- /dev/null +++ b/miniprogram/pages/test/disc.wxml @@ -0,0 +1,48 @@ + + + + + 问题 {{currentIndex + 1}}/{{total}} + 剩余时间: {{formatTime}} + + + + + + + + + {{currentQuestion.question}} + + + + + + {{option.text}} + + + + + + + + 上一题 + + + 跳过 + + + + + + {{isSubmitting ? '计算中...' : '完成测试,查看结果'}} + + + diff --git a/miniprogram/pages/test/disc.wxss b/miniprogram/pages/test/disc.wxss new file mode 100644 index 0000000..49f2191 --- /dev/null +++ b/miniprogram/pages/test/disc.wxss @@ -0,0 +1,172 @@ +/* pages/test/disc.wxss - 按旧版模板重构 */ +.test-page { + width: 100%; + min-height: 100vh; + display: flex; + flex-direction: column; + background-color: #fff; +} + +.progress-section { + padding: 32rpx; + border-bottom: 1rpx solid #e5e5e5; + flex-shrink: 0; +} + +.progress-info { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16rpx; +} + +.question-count { + font-size: 28rpx; + font-weight: 500; + color: #333; +} + +.time-remaining { + font-size: 28rpx; + color: #999; +} + +.progress-bar-container { + width: 100%; + height: 8rpx; + background-color: #e5e5e5; + border-radius: 8rpx; + overflow: hidden; +} + +.progress-bar { + height: 100%; + background: linear-gradient(135deg, #3B82F6 0%, #60a5fa 100%); + border-radius: 8rpx; + transition: width 0.3s ease; +} + +.content-area { + flex: 1; + overflow-y: auto; + padding: 32rpx; +} + +.question-card { + background-color: #fff; + border-radius: 24rpx; + padding: 48rpx; + box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08); +} + +.question-text { + display: block; + font-size: 40rpx; + font-weight: 500; + color: #333; + line-height: 1.6; + margin-bottom: 48rpx; +} + +.options-container { + display: flex; + flex-direction: column; + gap: 32rpx; +} + +.option-item { + display: flex; + align-items: center; + padding: 32rpx; + background: #fff; + border: 2rpx solid #e5e5e5; + border-radius: 16rpx; + transition: all 0.3s ease; +} + +.option-item.selected { + background-color: rgba(59, 130, 246, 0.1); + border-color: #3B82F6; +} + +.radio-button { + width: 40rpx; + height: 40rpx; + border-radius: 50%; + border: 2rpx solid #d1d5db; + display: flex; + align-items: center; + justify-content: center; + margin-right: 24rpx; + flex-shrink: 0; +} + +.radio-button.checked { + background-color: #3B82F6; + border-color: #3B82F6; +} + +.radio-inner { + width: 16rpx; + height: 16rpx; + border-radius: 50%; + background-color: #fff; +} + +.option-text { + flex: 1; + font-size: 32rpx; + color: #333; + line-height: 1.5; +} + +.footer-buttons { + display: flex; + gap: 24rpx; + padding: 32rpx; + border-top: 1rpx solid #e5e5e5; + flex-shrink: 0; +} + +.nav-button { + flex: 1; + padding: 28rpx; + border-radius: 16rpx; + text-align: center; +} + +.nav-button.secondary { + background-color: #fff; + border: 2rpx solid #3B82F6; +} + +.nav-button.secondary .button-text { + color: #3B82F6; +} + +.nav-button.disabled { + opacity: 0.4; + pointer-events: none; +} + +.button-text { + font-size: 32rpx; + font-weight: 500; +} + +.submit-wrap { + padding: 0 32rpx 32rpx; +} + +.submit-button { + background: linear-gradient(135deg, #3B82F6 0%, #60a5fa 100%); + border-radius: 16rpx; + padding: 28rpx; + text-align: center; +} + +.submit-text { + font-size: 32rpx; + font-weight: 600; + color: #fff; +} diff --git a/miniprogram/pages/test/mbti.js b/miniprogram/pages/test/mbti.js new file mode 100644 index 0000000..d4bb51e --- /dev/null +++ b/miniprogram/pages/test/mbti.js @@ -0,0 +1,180 @@ +// pages/test/mbti.js - MBTI测试页面逻辑 +const { mbtiQuestions, shuffleQuestions } = require('../../utils/questions') +const { mbtiDescriptions } = require('../../utils/descriptions') +const payment = require('../../utils/payment') +const app = getApp() + +Page({ + data: { + questions: [], + currentIndex: 0, + currentQuestion: null, + answers: {}, + selectedAnswer: null, + total: mbtiQuestions.length, + answeredCount: 0, + progress: 0, + timeRemaining: 30 * 60, // 30分钟 + formatTime: '30:00', + isSubmitting: false, + canAccess: false + }, + + timer: null, + + onLoad() { + const questions = shuffleQuestions(mbtiQuestions) + this.setData({ + questions, + currentQuestion: questions[0], + canAccess: true + }) + this.startTimer() + }, + + // 检查访问权限 + checkAccess() { + // 当前策略:所有测试免费开放,直接允许访问 + // 若后续恢复收费,可重新启用 payment.canTakeTest 等校验逻辑 + return true + }, + + onUnload() { + if (this.timer) { + clearInterval(this.timer) + } + }, + + // 启动计时器 + startTimer() { + this.timer = setInterval(() => { + let time = this.data.timeRemaining - 1 + if (time <= 0) { + clearInterval(this.timer) + this.submitTest() + return + } + const minutes = Math.floor(time / 60) + const seconds = time % 60 + this.setData({ + timeRemaining: time, + formatTime: `${minutes}:${seconds < 10 ? '0' : ''}${seconds}` + }) + }, 1000) + }, + + // 选择答案 + selectAnswer(e) { + const value = e.currentTarget.dataset.value + const questionId = this.data.currentQuestion.id + + let answers = { ...this.data.answers } + answers[questionId] = value + + this.setData({ + selectedAnswer: value, + answers: answers, + answeredCount: Object.keys(answers).length, + progress: (Object.keys(answers).length / this.data.total) * 100 + }) + + // 自动跳转下一题 + setTimeout(() => { + if (this.data.currentIndex < this.data.total - 1) { + this.nextQuestion() + } + }, 300) + }, + + // 上一题 + prevQuestion() { + if (this.data.currentIndex > 0) { + const newIndex = this.data.currentIndex - 1 + const newQuestion = this.data.questions[newIndex] + this.setData({ + currentIndex: newIndex, + currentQuestion: newQuestion, + selectedAnswer: this.data.answers[newQuestion.id] || null + }) + } + }, + + // 下一题 + nextQuestion() { + if (this.data.currentIndex < this.data.total - 1) { + const newIndex = this.data.currentIndex + 1 + const newQuestion = this.data.questions[newIndex] + this.setData({ + currentIndex: newIndex, + currentQuestion: newQuestion, + selectedAnswer: this.data.answers[newQuestion.id] || null + }) + } + }, + + // 提交测试 + submitTest() { + if (this.data.isSubmitting) return + this.setData({ isSubmitting: true }) + + const result = this.calculateResult() + + // 保存结果 + const resultData = { + ...result, + testDuration: 30 * 60 - this.data.timeRemaining, + completedAt: new Date().toISOString(), + timestamp: new Date().toISOString() + } + wx.setStorageSync('mbtiResult', resultData) + app.saveTestResult('mbti', resultData) + + // 跳转到结果页 + wx.redirectTo({ + url: '/pages/result/mbti' + }) + }, + + // 计算MBTI结果 + calculateResult() { + const answers = this.data.answers + const scores = { E: 0, I: 0, S: 0, N: 0, T: 0, F: 0, J: 0, P: 0 } + + // 统计各维度得分 + Object.values(answers).forEach(value => { + if (scores.hasOwnProperty(value)) { + scores[value]++ + } + }) + + // 确定MBTI类型 + const mbtiType = [ + scores.E >= scores.I ? 'E' : 'I', + scores.S >= scores.N ? 'S' : 'N', + scores.T >= scores.F ? 'T' : 'F', + scores.J >= scores.P ? 'J' : 'P' + ].join('') + + // 计算各维度百分比 + const dimensionScores = { + EI: { E: scores.E, I: scores.I, dominant: scores.E >= scores.I ? 'E' : 'I', percentage: Math.round((Math.max(scores.E, scores.I) / (scores.E + scores.I)) * 100) }, + SN: { S: scores.S, N: scores.N, dominant: scores.S >= scores.N ? 'S' : 'N', percentage: Math.round((Math.max(scores.S, scores.N) / (scores.S + scores.N)) * 100) }, + TF: { T: scores.T, F: scores.F, dominant: scores.T >= scores.F ? 'T' : 'F', percentage: Math.round((Math.max(scores.T, scores.F) / (scores.T + scores.F)) * 100) }, + JP: { J: scores.J, P: scores.P, dominant: scores.J >= scores.P ? 'J' : 'P', percentage: Math.round((Math.max(scores.J, scores.P) / (scores.J + scores.P)) * 100) } + } + + // 计算置信度 + const confidence = Math.round( + (dimensionScores.EI.percentage + dimensionScores.SN.percentage + + dimensionScores.TF.percentage + dimensionScores.JP.percentage) / 4 + ) + + return { + mbtiType, + scores, + dimensionScores, + confidence, + description: mbtiDescriptions[mbtiType] || {} + } + } +}) diff --git a/miniprogram/pages/test/mbti.json b/miniprogram/pages/test/mbti.json new file mode 100644 index 0000000..8224f00 --- /dev/null +++ b/miniprogram/pages/test/mbti.json @@ -0,0 +1,6 @@ +{ + "navigationBarTitleText": "MBTI测试", + "navigationBarBackgroundColor": "#FF6B8A", + "navigationBarTextStyle": "white", + "usingComponents": {} +} diff --git a/miniprogram/pages/test/mbti.wxml b/miniprogram/pages/test/mbti.wxml new file mode 100644 index 0000000..04a538d --- /dev/null +++ b/miniprogram/pages/test/mbti.wxml @@ -0,0 +1,48 @@ + + + + + 问题 {{currentIndex + 1}}/{{total}} + 剩余时间: {{formatTime}} + + + + + + + + + {{currentQuestion.question}} + + + + + + {{option.text}} + + + + + + + + 上一题 + + + 跳过 + + + + + + {{isSubmitting ? '计算中...' : '完成测试,查看结果'}} + + + diff --git a/miniprogram/pages/test/mbti.wxss b/miniprogram/pages/test/mbti.wxss new file mode 100644 index 0000000..1927ace --- /dev/null +++ b/miniprogram/pages/test/mbti.wxss @@ -0,0 +1,172 @@ +/* pages/test/mbti.wxss - 按旧版模板重构 */ +.test-page { + width: 100%; + min-height: 100vh; + display: flex; + flex-direction: column; + background-color: #fff; +} + +.progress-section { + padding: 32rpx; + border-bottom: 1rpx solid #e5e5e5; + flex-shrink: 0; +} + +.progress-info { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16rpx; +} + +.question-count { + font-size: 28rpx; + font-weight: 500; + color: #333; +} + +.time-remaining { + font-size: 28rpx; + color: #999; +} + +.progress-bar-container { + width: 100%; + height: 8rpx; + background-color: #e5e5e5; + border-radius: 8rpx; + overflow: hidden; +} + +.progress-bar { + height: 100%; + background: linear-gradient(135deg, #FF6B8A 0%, #ff8fa3 100%); + border-radius: 8rpx; + transition: width 0.3s ease; +} + +.content-area { + flex: 1; + overflow-y: auto; + padding: 32rpx; +} + +.question-card { + background-color: #fff; + border-radius: 24rpx; + padding: 48rpx; + box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08); +} + +.question-text { + display: block; + font-size: 40rpx; + font-weight: 500; + color: #333; + line-height: 1.6; + margin-bottom: 48rpx; +} + +.options-container { + display: flex; + flex-direction: column; + gap: 32rpx; +} + +.option-item { + display: flex; + align-items: center; + padding: 32rpx; + border: 2rpx solid #e5e5e5; + border-radius: 16rpx; + transition: all 0.3s ease; +} + +.option-item.selected { + background-color: rgba(255, 107, 138, 0.12); + border-color: #FF6B8A; +} + +.radio-button { + width: 40rpx; + height: 40rpx; + border-radius: 50%; + border: 2rpx solid #d1d5db; + display: flex; + align-items: center; + justify-content: center; + margin-right: 24rpx; + flex-shrink: 0; + transition: all 0.3s ease; +} + +.radio-button.checked { + background-color: #FF6B8A; + border-color: #FF6B8A; +} + +.radio-inner { + width: 16rpx; + height: 16rpx; + border-radius: 50%; + background-color: #fff; +} + +.option-text { + flex: 1; + font-size: 32rpx; + color: #333; + line-height: 1.5; +} + +.footer-buttons { + display: flex; + gap: 24rpx; + padding: 32rpx; + border-top: 1rpx solid #e5e5e5; + flex-shrink: 0; +} + +.nav-button { + flex: 1; + padding: 28rpx; + border-radius: 16rpx; + text-align: center; +} + +.nav-button.secondary { + background-color: #fff; + border: 2rpx solid #FF6B8A; +} + +.nav-button.secondary .button-text { + color: #FF6B8A; +} + +.nav-button.disabled { + opacity: 0.4; + pointer-events: none; +} + +.button-text { + font-size: 32rpx; + font-weight: 500; +} + +.submit-wrap { + padding: 0 32rpx 32rpx; +} + +.submit-button { + background: linear-gradient(135deg, #FF6B8A 0%, #ff8fa3 100%); + border-radius: 16rpx; + padding: 28rpx; + text-align: center; +} + +.submit-text { + font-size: 32rpx; + font-weight: 600; + color: #fff; +} diff --git a/miniprogram/pages/test/pdp.js b/miniprogram/pages/test/pdp.js new file mode 100644 index 0000000..33e067f --- /dev/null +++ b/miniprogram/pages/test/pdp.js @@ -0,0 +1,137 @@ +// pages/test/pdp.js +const { pdpQuestions, shuffleQuestions } = require('../../utils/questions') +const { pdpDescriptions } = require('../../utils/descriptions') +const app = getApp() + +Page({ + data: { + questions: [], + currentIndex: 0, + currentQuestion: null, + answers: {}, + selectedAnswer: null, + total: pdpQuestions.length, + answeredCount: 0, + progress: 0, + timeRemaining: 15 * 60, + formatTime: '15:00', + isSubmitting: false + }, + + timer: null, + + onLoad() { + const questions = shuffleQuestions(pdpQuestions) + this.setData({ questions, currentQuestion: questions[0] }) + this.startTimer() + }, + + onUnload() { + if (this.timer) clearInterval(this.timer) + }, + + startTimer() { + this.timer = setInterval(() => { + let time = this.data.timeRemaining - 1 + if (time <= 0) { + clearInterval(this.timer) + this.submitTest() + return + } + const minutes = Math.floor(time / 60) + const seconds = time % 60 + this.setData({ + timeRemaining: time, + formatTime: `${minutes}:${seconds < 10 ? '0' : ''}${seconds}` + }) + }, 1000) + }, + + getEmoji(value) { + const emojis = { Tiger: '🐅', Peacock: '🦚', Koala: '🐨', Owl: '🦉', Chameleon: '🦎' } + return emojis[value] || '🔹' + }, + + selectAnswer(e) { + const value = e.currentTarget.dataset.value + const questionId = this.data.currentQuestion.id + let answers = { ...this.data.answers } + answers[questionId] = value + + this.setData({ + selectedAnswer: value, + answers: answers, + answeredCount: Object.keys(answers).length, + progress: (Object.keys(answers).length / this.data.total) * 100 + }) + + setTimeout(() => { + if (this.data.currentIndex < this.data.total - 1) { + this.nextQuestion() + } + }, 300) + }, + + prevQuestion() { + if (this.data.currentIndex > 0) { + const newIndex = this.data.currentIndex - 1 + const newQuestion = this.data.questions[newIndex] + this.setData({ + currentIndex: newIndex, + currentQuestion: newQuestion, + selectedAnswer: this.data.answers[newQuestion.id] || null + }) + } + }, + + nextQuestion() { + if (this.data.currentIndex < this.data.total - 1) { + const newIndex = this.data.currentIndex + 1 + const newQuestion = this.data.questions[newIndex] + this.setData({ + currentIndex: newIndex, + currentQuestion: newQuestion, + selectedAnswer: this.data.answers[newQuestion.id] || null + }) + } + }, + + submitTest() { + if (this.data.isSubmitting) return + this.setData({ isSubmitting: true }) + + const scores = { Tiger: 0, Peacock: 0, Koala: 0, Owl: 0, Chameleon: 0 } + Object.values(this.data.answers).forEach(value => { + if (scores.hasOwnProperty(value)) scores[value]++ + }) + + const total = Object.values(scores).reduce((sum, v) => sum + v, 0) + const percentages = {} + Object.keys(scores).forEach(key => { + percentages[key] = Math.round((scores[key] / total) * 100) + }) + + const dominantType = Object.entries(scores).sort((a, b) => b[1] - a[1])[0][0] + const secondaryType = Object.entries(scores).sort((a, b) => b[1] - a[1])[1][0] + + const resultData = { + scores, + percentages, + dominantType, + secondaryType, + description: pdpDescriptions[dominantType], + testDuration: 15 * 60 - this.data.timeRemaining, + completedAt: new Date().toISOString(), + // 便于后端留存完整答题过程 + answers: this.data.answers + } + + // 本地缓存 + 全局缓存 + wx.setStorageSync('pdpResult', resultData) + if (app && typeof app.saveTestResult === 'function') { + app.saveTestResult('pdp', resultData) + } + + wx.redirectTo({ url: '/pages/result/pdp' }) + } +}) diff --git a/miniprogram/pages/test/pdp.json b/miniprogram/pages/test/pdp.json new file mode 100644 index 0000000..ffe77f1 --- /dev/null +++ b/miniprogram/pages/test/pdp.json @@ -0,0 +1,6 @@ +{ + "navigationBarTitleText": "PDP测试", + "navigationBarBackgroundColor": "#F59E0B", + "navigationBarTextStyle": "white", + "usingComponents": {} +} diff --git a/miniprogram/pages/test/pdp.wxml b/miniprogram/pages/test/pdp.wxml new file mode 100644 index 0000000..8fe9180 --- /dev/null +++ b/miniprogram/pages/test/pdp.wxml @@ -0,0 +1,48 @@ + + + + + 问题 {{currentIndex + 1}}/{{total}} + 剩余时间: {{formatTime}} + + + + + + + + + {{currentQuestion.question}} + + + + + + {{option.text}} + + + + + + + + 上一题 + + + 跳过 + + + + + + {{isSubmitting ? '计算中...' : '完成测试,查看结果'}} + + + diff --git a/miniprogram/pages/test/pdp.wxss b/miniprogram/pages/test/pdp.wxss new file mode 100644 index 0000000..0b815c5 --- /dev/null +++ b/miniprogram/pages/test/pdp.wxss @@ -0,0 +1,171 @@ +/* pages/test/pdp.wxss - 按旧版模板重构 */ +.test-page { + width: 100%; + min-height: 100vh; + display: flex; + flex-direction: column; + background-color: #fff; +} + +.progress-section { + padding: 32rpx; + border-bottom: 1rpx solid #e5e5e5; + flex-shrink: 0; +} + +.progress-info { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16rpx; +} + +.question-count { + font-size: 28rpx; + font-weight: 500; + color: #333; +} + +.time-remaining { + font-size: 28rpx; + color: #999; +} + +.progress-bar-container { + width: 100%; + height: 8rpx; + background-color: #e5e5e5; + border-radius: 8rpx; + overflow: hidden; +} + +.progress-bar { + height: 100%; + background: linear-gradient(135deg, #F59E0B 0%, #fbbf24 100%); + border-radius: 8rpx; + transition: width 0.3s ease; +} + +.content-area { + flex: 1; + overflow-y: auto; + padding: 32rpx; +} + +.question-card { + background-color: #fff; + border-radius: 24rpx; + padding: 48rpx; + box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08); +} + +.question-text { + display: block; + font-size: 40rpx; + font-weight: 500; + color: #333; + line-height: 1.6; + margin-bottom: 48rpx; +} + +.options-container { + display: flex; + flex-direction: column; + gap: 32rpx; +} + +.option-item { + display: flex; + align-items: center; + padding: 32rpx; + border: 2rpx solid #e5e5e5; + border-radius: 16rpx; + transition: all 0.3s ease; +} + +.option-item.selected { + background-color: rgba(245, 158, 11, 0.12); + border-color: #F59E0B; +} + +.radio-button { + width: 40rpx; + height: 40rpx; + border-radius: 50%; + border: 2rpx solid #d1d5db; + display: flex; + align-items: center; + justify-content: center; + margin-right: 24rpx; + flex-shrink: 0; +} + +.radio-button.checked { + background-color: #F59E0B; + border-color: #F59E0B; +} + +.radio-inner { + width: 16rpx; + height: 16rpx; + border-radius: 50%; + background-color: #fff; +} + +.option-text { + flex: 1; + font-size: 32rpx; + color: #333; + line-height: 1.5; +} + +.footer-buttons { + display: flex; + gap: 24rpx; + padding: 32rpx; + border-top: 1rpx solid #e5e5e5; + flex-shrink: 0; +} + +.nav-button { + flex: 1; + padding: 28rpx; + border-radius: 16rpx; + text-align: center; +} + +.nav-button.secondary { + background-color: #fff; + border: 2rpx solid #F59E0B; +} + +.nav-button.secondary .button-text { + color: #F59E0B; +} + +.nav-button.disabled { + opacity: 0.4; + pointer-events: none; +} + +.button-text { + font-size: 32rpx; + font-weight: 500; +} + +.submit-wrap { + padding: 0 32rpx 32rpx; +} + +.submit-button { + background: linear-gradient(135deg, #F59E0B 0%, #fbbf24 100%); + border-radius: 16rpx; + padding: 28rpx; + text-align: center; +} + +.submit-text { + font-size: 32rpx; + font-weight: 600; + color: #fff; +} diff --git a/miniprogram/pages/user-profile/index.js b/miniprogram/pages/user-profile/index.js new file mode 100644 index 0000000..3731e70 --- /dev/null +++ b/miniprogram/pages/user-profile/index.js @@ -0,0 +1,205 @@ +// pages/user-profile/index.js - 个人资料页 +const app = getApp() +const { request } = require('../../utils/request') +const { bindPhoneByCode } = require('../../utils/phoneAuth') + +Page({ + data: { + userInfo: null, + nickname: '', + avatar: '', + birthday: '', + gender: 0, + genderIndex: 0, + genderText: '', + genderOptions: ['保密', '男', '女'], + phone: '', + avatarLetter: '我', + avatarBgColor: '#6366f1', + nicknameFocused: false, + saving: false + }, + + onLoad() { + this.loadUserInfo() + }, + + onShow() { + // 从其他页返回时刷新 + if (app.globalData.userInfo) { + this.loadUserInfo() + } + }, + + loadUserInfo() { + const userInfo = app.globalData.userInfo || wx.getStorageSync('userInfo') + if (!userInfo) { + wx.showToast({ title: '请先登录', icon: 'none' }) + setTimeout(() => wx.navigateBack(), 1500) + return + } + + const nickname = (userInfo.nickname || userInfo.nickName || '').trim() + const avatar = userInfo.avatar || userInfo.avatarUrl || '' + const birthday = userInfo.birthday || '' + const gender = (userInfo.gender !== undefined && userInfo.gender !== null) ? Number(userInfo.gender) : 0 + const genderText = this._genderText(gender) + const genderIndex = Math.min(Math.max(0, gender), 2) + const phone = userInfo.phone || '' + + const { avatarLetter, avatarBgColor } = this._avatarFromNickname(nickname || '我') + + this.setData({ + userInfo, + nickname, + avatar, + birthday, + gender, + genderIndex, + genderText, + phone, + avatarLetter, + avatarBgColor + }) + }, + + _genderText(g) { + const map = { 0: '保密', 1: '男', 2: '女' } + return map[g] || '保密' + }, + + _avatarFromNickname(name) { + const str = (name && String(name).trim()) || '我' + const letter = str.charAt(0).toUpperCase() || '我' + const palette = ['#6366f1', '#8b5cf6', '#ec4899', '#f43f5e', '#14b8a6', '#0ea5e9', '#3b82f6', '#eab308'] + let hash = 0 + for (let i = 0; i < str.length; i++) hash += str.charCodeAt(i) + const bgColor = palette[Math.abs(hash) % palette.length] + return { avatarLetter: letter, avatarBgColor: bgColor } + }, + + onChooseAvatar(e) { + const { avatarUrl } = e.detail + if (!avatarUrl || !app.globalData.token) { + wx.showToast({ title: '请先登录', icon: 'none' }) + return + } + wx.showLoading({ title: '上传中...' }) + const token = app.globalData.token + const apiBase = app.globalData.apiBase + wx.uploadFile({ + url: `${apiBase}/api/upload/image`, + filePath: avatarUrl, + name: 'file', + header: { 'Authorization': `Bearer ${token}` }, + success: (res) => { + const data = res.data + let json = {} + try { json = typeof data === 'string' ? JSON.parse(data) : data } catch (_) {} + if (json.code === 200 && json.data && json.data.url) { + const avatar = json.data.url + this.setData({ avatar }) + this._saveProfile({ avatar }) + } else { + wx.hideLoading() + wx.showToast({ title: json.message || '上传失败', icon: 'none' }) + } + }, + fail: () => { + wx.hideLoading() + wx.showToast({ title: '上传失败', icon: 'none' }) + } + }) + }, + + onGetPhoneNumber(e) { + const { errMsg, code } = e.detail || {} + if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) { + wx.showToast({ title: '需要授权手机号才能获取', icon: 'none' }) + return + } + if (!code) { + wx.showToast({ title: '获取手机号失败', icon: 'none' }) + return + } + bindPhoneByCode(code).then((user) => { + this.setData({ phone: user.phone || '' }) + }).catch(() => {}) + }, + + onNicknameRowTap() { + this.setData({ nicknameFocused: true }) + }, + + onNicknameBlur() { + this.setData({ nicknameFocused: false }) + }, + + onNicknameChange(e) { + const nickname = (e?.detail?.value || '').trim() + const { avatarLetter, avatarBgColor } = this._avatarFromNickname(nickname || '我') + this.setData({ nickname, avatarLetter, avatarBgColor }) + }, + + onBirthdayChange(e) { + const birthday = (e?.detail?.value || '').trim() + this.setData({ birthday }) + }, + + onGenderChange(e) { + const idx = parseInt(e?.detail?.value, 10) || 0 + const gender = idx + const genderText = this.data.genderOptions[idx] || '保密' + this.setData({ gender, genderIndex: idx, genderText }) + }, + + onSave() { + const { nickname, birthday, gender, userInfo } = this.data + const profile = {} + const origNickname = (userInfo?.nickname || userInfo?.nickName || '').trim() + const origBirthday = userInfo?.birthday || '' + const origGender = (userInfo?.gender !== undefined && userInfo?.gender !== null) ? Number(userInfo.gender) : 0 + if (nickname !== origNickname) profile.nickname = nickname + if (birthday !== origBirthday) profile.birthday = birthday + if (gender !== origGender) profile.gender = gender + if (Object.keys(profile).length === 0) { + wx.showToast({ title: '暂无修改', icon: 'none' }) + return + } + this._saveProfile(profile) + }, + + _saveProfile(profile) { + if (!app.globalData.token || !profile || Object.keys(profile).length === 0) return + if (this.data.saving) return + this.setData({ saving: true }) + wx.showLoading({ title: '保存中...' }) + request({ + url: '/api/auth/wechat/profile', + method: 'PUT', + data: profile, + success: (res) => { + wx.hideLoading() + this.setData({ saving: false }) + const payload = res && res.data + if (payload && payload.code === 200) { + const updated = { ...(app.globalData.userInfo || {}), ...(payload.data || {}), ...profile } + if (profile.nickname !== undefined) updated.nickname = profile.nickname + if (profile.avatar !== undefined) updated.avatar = profile.avatar + if (profile.birthday !== undefined) updated.birthday = profile.birthday + if (profile.gender !== undefined) updated.gender = profile.gender + app.globalData.userInfo = updated + wx.setStorageSync('userInfo', updated) + wx.showToast({ title: '已保存', icon: 'success' }) + } else { + wx.showToast({ title: payload?.message || '保存失败', icon: 'none' }) + } + }, + fail: () => { + wx.hideLoading() + this.setData({ saving: false }) + wx.showToast({ title: '网络错误', icon: 'none' }) + } + }) + } +}) diff --git a/miniprogram/pages/user-profile/index.json b/miniprogram/pages/user-profile/index.json new file mode 100644 index 0000000..7318c6a --- /dev/null +++ b/miniprogram/pages/user-profile/index.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "个人资料", + "usingComponents": {} +} diff --git a/miniprogram/pages/user-profile/index.wxml b/miniprogram/pages/user-profile/index.wxml new file mode 100644 index 0000000..0d7c2b0 --- /dev/null +++ b/miniprogram/pages/user-profile/index.wxml @@ -0,0 +1,66 @@ + + + + + + + 点击更换头像 + + + + 头像、昵称、手机号为必填项 + + + + + + 昵称 + + + + + + + 生日 + + {{birthday || '请选择生日'}} + + + + + + + + 性别 + + {{genderText}} + + + + + + + 手机号 + + + + + + + + diff --git a/miniprogram/pages/user-profile/index.wxss b/miniprogram/pages/user-profile/index.wxss new file mode 100644 index 0000000..a2604f5 --- /dev/null +++ b/miniprogram/pages/user-profile/index.wxss @@ -0,0 +1,206 @@ +/* pages/user-profile/index.wxss */ +page { + background-color: #F9FAFB; +} + +.page { + min-height: 100vh; + padding: 32rpx; +} + +.profile-card { + background: #FFFFFF; + border-radius: 28rpx; + padding: 48rpx 36rpx; + box-shadow: 0 4rpx 24rpx rgba(0,0,0,0.05); +} + +.avatar-section { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 48rpx; +} + +.avatar-btn { + width: 160rpx !important; + height: 160rpx !important; + padding: 0 !important; + margin: 0 !important; + background: transparent !important; + border: none !important; +} + +.avatar-btn::after { + border: none !important; +} + +.avatar-img { + width: 160rpx; + height: 160rpx; + border-radius: 50%; + display: block; + border: 4rpx solid #F3F4F6; +} + +.avatar-letter-wrap { + width: 160rpx; + height: 160rpx; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + border: 4rpx solid #F3F4F6; +} + +.avatar-letter { + font-size: 72rpx; + font-weight: 700; + color: #FFFFFF; +} + +.avatar-hint { + font-size: 24rpx; + color: #9CA3AF; + margin-top: 16rpx; +} + +.required-tip { + font-size: 24rpx; + color: #9CA3AF; + margin-bottom: 24rpx; + padding: 0 4rpx; +} + +.form-section { + border-top: 1rpx solid #F3F4F6; +} + +.form-item { + display: flex; + align-items: center; + padding: 32rpx 0; + border-bottom: 1rpx solid #F3F4F6; +} + +.form-item:last-child { + border-bottom: none; +} + +.form-label { + width: 140rpx; + font-size: 28rpx; + color: #6B7280; + flex-shrink: 0; +} + +.form-input { + flex: 1; + font-size: 30rpx; + color: #111827; + min-width: 0; +} + +.form-picker { + flex: 1; + font-size: 30rpx; + color: #111827; + min-width: 0; +} + +.form-value { + font-size: 30rpx; + color: #111827; +} + +.form-placeholder { + color: #9CA3AF; +} + +.form-readonly { + color: #9CA3AF; +} + +.form-picker { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.form-arrow { + font-size: 36rpx; + color: #D1D5DB; + margin-left: 16rpx; + flex-shrink: 0; +} + +/* 整行可点击 */ +.form-item-tappable { + cursor: pointer; +} + +.form-item-tappable:active { + background: #F9FAFB; +} + +.form-item-readonly { + /* 仅展示,点击有提示 */ +} + +.form-item-readonly:active { + background: #F9FAFB; +} + +/* 手机号授权按钮 */ +.form-item-phone-auth { + padding: 0; +} + +.phone-auth-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: space-between; + padding: 32rpx 0; + margin: 0; + background: transparent; + border: none; + text-align: left; +} + +.phone-auth-btn::after { + border: none; +} + +.phone-auth-text { + font-size: 30rpx; + color: #7C3AED; + font-weight: 500; +} + +.picker-full-row { + display: block; + width: 100%; +} + +/* 保存按钮 */ +.save-btn { + width: 100%; + margin-top: 48rpx; + padding: 28rpx; + font-size: 32rpx; + font-weight: 600; + color: #FFFFFF; + background: linear-gradient(135deg, #7C3AED 0%, #6366F1 100%); + border-radius: 16rpx; + border: none; +} + +.save-btn::after { + border: none; +} + +.save-btn[disabled] { + opacity: 0.6; +} diff --git a/miniprogram/project.config.json b/miniprogram/project.config.json new file mode 100644 index 0000000..ec384e5 --- /dev/null +++ b/miniprogram/project.config.json @@ -0,0 +1,51 @@ +{ + "miniprogramRoot": "", + "description": "MBTI/DISC/PDP性格测试系统 - 个人版与企业版", + "setting": { + "urlCheck": false, + "es6": true, + "enhance": true, + "postcss": true, + "preloadBackgroundData": false, + "minified": true, + "newFeature": true, + "coverView": true, + "nodeModules": false, + "autoAudits": false, + "showShadowRootInWxmlPanel": true, + "scopeDataCheck": false, + "uglifyFileName": false, + "checkInvalidKey": true, + "checkSiteMap": true, + "uploadWithSourceMap": true, + "compileHotReLoad": false, + "lazyloadPlaceholderEnable": false, + "useMultiFrameRuntime": true, + "babelSetting": { + "ignore": [], + "disablePlugins": [], + "outputPath": "" + }, + "useIsolateContext": true, + "userConfirmedBundleSwitch": false, + "packNpmManually": false, + "packNpmRelationList": [], + "minifyWXSS": true, + "disableUseStrict": false, + "showES6CompileOption": false, + "useCompilerPlugins": false, + "ignoreUploadUnusedFiles": true + }, + "compileType": "miniprogram", + "condition": {}, + "editorSetting": { + "tabIndent": "insertSpaces", + "tabSize": 4 + }, + "libVersion": "3.14.1", + "packOptions": { + "ignore": [], + "include": [] + }, + "appid": "wx3d15ed02e98b04e3" +} diff --git a/miniprogram/project.private.config.json b/miniprogram/project.private.config.json new file mode 100644 index 0000000..82b39a7 --- /dev/null +++ b/miniprogram/project.private.config.json @@ -0,0 +1,34 @@ +{ + "description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档:https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html", + "projectname": "MBTI人格测试系统", + "setting": { + "compileHotReLoad": true + }, + "condition": { + "miniprogram": { + "list": [ + { + "name": "pages/enterprise/index", + "pathName": "pages/enterprise/index", + "query": "scene=uid%253D6%2526eid%253D6", + "scene": null, + "launchMode": "default" + }, + { + "name": "pages/enterprise/index", + "pathName": "pages/enterprise/index", + "query": "scene=e_6", + "launchMode": "default", + "scene": null + }, + { + "name": "pages/enterprise/index", + "pathName": "pages/enterprise/index", + "query": "scene=e_6", + "launchMode": "default", + "scene": null + } + ] + } + } +} \ No newline at end of file diff --git a/miniprogram/sitemap.json b/miniprogram/sitemap.json new file mode 100644 index 0000000..55d1d29 --- /dev/null +++ b/miniprogram/sitemap.json @@ -0,0 +1,7 @@ +{ + "desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html", + "rules": [{ + "action": "allow", + "page": "*" + }] +} diff --git a/miniprogram/utils/descriptions.js b/miniprogram/utils/descriptions.js new file mode 100644 index 0000000..43f60e6 --- /dev/null +++ b/miniprogram/utils/descriptions.js @@ -0,0 +1,44 @@ +// utils/descriptions.js - 测试结果描述 + +// MBTI类型描述 +const mbtiDescriptions = { + ISTJ: { type: "ISTJ", name: "检查员", category: "守护者", description: "务实、负责、可靠的传统主义者", strengths: ["可靠负责", "注重细节", "有条理"], weaknesses: ["可能过于固执", "不喜变化"], careers: ["会计师", "审计师", "项目经理"] }, + ISFJ: { type: "ISFJ", name: "保护者", category: "守护者", description: "安静、友好、负责任的守护者", strengths: ["忠诚体贴", "观察力强", "耐心"], weaknesses: ["不善拒绝", "过于谦虚"], careers: ["护士", "教师", "行政"] }, + INFJ: { type: "INFJ", name: "提倡者", category: "理想主义者", description: "寻求意义和联系的理想主义者", strengths: ["洞察力强", "有远见", "坚定"], weaknesses: ["过于理想化", "容易疲惫"], careers: ["心理咨询", "作家", "人力资源"] }, + INTJ: { type: "INTJ", name: "建筑师", category: "理想主义者", description: "独立、有战略眼光的思考者", strengths: ["战略思维", "独立自信", "意志坚定"], weaknesses: ["可能傲慢", "过于苛刻"], careers: ["战略顾问", "科学家", "架构师"] }, + ISTP: { type: "ISTP", name: "鉴赏家", category: "探险家", description: "灵活、务实的问题解决者", strengths: ["适应力强", "动手能力", "冷静"], weaknesses: ["可能冷漠", "不善表达"], careers: ["工程师", "技术员", "飞行员"] }, + ISFP: { type: "ISFP", name: "探险家", category: "探险家", description: "温和、敏感的艺术家", strengths: ["创造力", "同理心", "灵活"], weaknesses: ["过于敏感", "避免冲突"], careers: ["设计师", "艺术家", "摄影师"] }, + INFP: { type: "INFP", name: "调停者", category: "理想主义者", description: "理想主义、忠诚的调解者", strengths: ["创造力", "同理心", "真诚"], weaknesses: ["过于理想化", "情绪化"], careers: ["作家", "心理咨询", "社工"] }, + INTP: { type: "INTP", name: "逻辑学家", category: "理想主义者", description: "创新、逻辑的思考者", strengths: ["逻辑思维", "创新能力", "客观"], weaknesses: ["可能孤僻", "忽视情感"], careers: ["程序员", "研究员", "分析师"] }, + ESTP: { type: "ESTP", name: "企业家", category: "探险家", description: "精力充沛、务实的行动者", strengths: ["果断", "务实", "善于应变"], weaknesses: ["可能冲动", "缺乏耐心"], careers: ["销售", "企业家", "运动员"] }, + ESFP: { type: "ESFP", name: "表演者", category: "探险家", description: "热情、友好的社交达人", strengths: ["热情友好", "乐观", "灵活"], weaknesses: ["可能肤浅", "容易分心"], careers: ["演员", "销售", "主持人"] }, + ENFP: { type: "ENFP", name: "竞选者", category: "理想主义者", description: "热情、有创造力的社交者", strengths: ["创造力", "热情", "善于沟通"], weaknesses: ["可能不切实际", "缺乏专注"], careers: ["市场营销", "记者", "顾问"] }, + ENTP: { type: "ENTP", name: "辩论家", category: "理想主义者", description: "聪明、好奇的思想家", strengths: ["创新能力", "辩论能力", "适应力"], weaknesses: ["可能争辩", "不善执行"], careers: ["律师", "企业家", "咨询"] }, + ESTJ: { type: "ESTJ", name: "总经理", category: "守护者", description: "务实、果断的组织者", strengths: ["领导力", "组织能力", "务实"], weaknesses: ["可能专制", "不够灵活"], careers: ["管理者", "项目经理", "律师"] }, + ESFJ: { type: "ESFJ", name: "执政官", category: "守护者", description: "热心、合作的支持者", strengths: ["关心他人", "负责任", "善于合作"], weaknesses: ["过于在意评价", "不善拒绝"], careers: ["教师", "护士", "人力资源"] }, + ENFJ: { type: "ENFJ", name: "主人公", category: "理想主义者", description: "有魅力、鼓舞人心的领导者", strengths: ["领导力", "同理心", "说服力"], weaknesses: ["过于理想化", "过度付出"], careers: ["培训师", "顾问", "教师"] }, + ENTJ: { type: "ENTJ", name: "指挥官", category: "理想主义者", description: "大胆、有远见的领导者", strengths: ["领导力", "战略思维", "果断"], weaknesses: ["可能专制", "不耐烦"], careers: ["CEO", "企业家", "律师"] } +} + +// DISC类型描述 +const discDescriptions = { + D: { type: "D型", title: "支配型", color: "#EF4444", description: "天生的领导者,注重结果和效率", strengths: ["决断力强", "目标导向", "行动迅速"], weaknesses: ["可能过于强势", "缺乏耐心"], careers: ["企业高管", "创业者", "项目经理"] }, + I: { type: "I型", title: "影响型", color: "#F59E0B", description: "热情友好,善于社交和表达", strengths: ["善于沟通", "乐观积极", "创意丰富"], weaknesses: ["可能过于乐观", "注意力分散"], careers: ["市场营销", "公关", "培训师"] }, + S: { type: "S型", title: "稳健型", color: "#10B981", description: "稳重可靠,注重团队和谐", strengths: ["可靠稳定", "团队协作", "耐心倾听"], weaknesses: ["可能抗拒变化", "决策较慢"], careers: ["人力资源", "客户服务", "行政"] }, + C: { type: "C型", title: "谨慎型", color: "#3B82F6", description: "注重细节和准确性,追求完美", strengths: ["分析能力强", "注重细节", "准确严谨"], weaknesses: ["可能过于完美主义", "决策较慢"], careers: ["数据分析", "工程师", "会计师"] } +} + +// PDP类型描述 +const pdpDescriptions = { + Tiger: { type: "老虎型", emoji: "🐅", title: "支配者", color: "#F59E0B", description: "天生的领导者,具有强烈的目标导向和执行力", strengths: ["决断力强", "目标导向", "行动迅速"], weaknesses: ["可能过于强势", "缺乏耐心"], careers: ["企业高管", "创业者", "项目经理"], teamRole: "适合担任领导者角色" }, + Peacock: { type: "孔雀型", emoji: "🦚", title: "表现者", color: "#8B5CF6", description: "热情友好,善于表达和社交", strengths: ["善于沟通", "乐观积极", "影响力强"], weaknesses: ["可能过于乐观", "注意力分散"], careers: ["市场营销", "公关", "培训师"], teamRole: "适合担任激励者和调解者角色" }, + Koala: { type: "考拉型", emoji: "🐨", title: "支持者", color: "#10B981", description: "稳重可靠,注重团队和谐", strengths: ["可靠稳定", "团队协作", "善解人意"], weaknesses: ["可能抗拒变化", "决策较慢"], careers: ["人力资源", "客户服务", "教师"], teamRole: "适合担任协调者和支持者角色" }, + Owl: { type: "猫头鹰型", emoji: "🦉", title: "分析者", color: "#3B82F6", description: "注重细节和准确性,追求完美", strengths: ["分析能力强", "注重细节", "系统思维"], weaknesses: ["可能过于完美主义", "不善社交"], careers: ["数据分析师", "工程师", "研究员"], teamRole: "适合担任专家和质量控制者角色" }, + Chameleon: { type: "变色龙型", emoji: "🦎", title: "整合者", color: "#06B6D4", description: "适应能力强,能在不同情境中灵活调整", strengths: ["适应力强", "灵活多变", "平衡能力"], weaknesses: ["可能缺乏主见", "身份认同模糊"], careers: ["咨询顾问", "协调员", "自由职业者"], teamRole: "适合担任协调者和多面手角色" } +} + +module.exports = { + mbtiDescriptions, + discDescriptions, + pdpDescriptions +} diff --git a/miniprogram/utils/payment.js b/miniprogram/utils/payment.js new file mode 100644 index 0000000..b29971b --- /dev/null +++ b/miniprogram/utils/payment.js @@ -0,0 +1,628 @@ +// miniprogram/utils/payment.js +// 微信支付工具类 - 复刻自Soul项目 + +const app = getApp() + +/** + * 生成符合微信规则的订单号 + * 规则示例:FACE20260226151900001(前缀+时间戳+随机数,最长不超过32位) + * @param {String} productType - 业务类型,如 face/mbti/disc/pdp/report/team_analysis/vip 等 + */ +function generateOrderId(productType) { + const now = new Date() + const pad = (n, len = 2) => String(n).padStart(len, '0') + const prefixMap = { + face: 'FACE', + mbti: 'MBTI', + disc: 'DISC', + pdp: 'PDP', + report: 'REPT', + team_analysis: 'TEAM', + vip: 'VIP', + test_count: 'TNUM', + single_test: 'TSGL', + recharge: 'RCG', + deep_personal: 'DPER', + deep_team: 'DTEAM' + } + + const prefix = prefixMap[productType] || (productType || 'ORD').toUpperCase().slice(0, 6) + const dateStr = + now.getFullYear().toString() + + pad(now.getMonth() + 1) + + pad(now.getDate()) + + pad(now.getHours()) + + pad(now.getMinutes()) + + pad(now.getSeconds()) + + // 3位随机数,避免重复,如 001~999 + const rand = pad(Math.floor(Math.random() * 1000), 3) + const raw = `${prefix}${dateStr}${rand}` // 示例:FACE20260226151900001 + + // 微信 out_trade_no 最长 32 字节,这里兜底截断 + return raw.length > 32 ? raw.slice(0, 32) : raw +} + +/** + * 发起微信支付 + * @param {Object} options - 支付选项 + * @param {String} options.orderId - 订单ID + * @param {Number} [options.amount] - 支付金额(分),部分产品使用(如充值/VIP/测试次数等);深度服务与按配置定价的产品传 0 或不传 + * @param {String} options.description - 商品描述 + * @param {String} options.productType - 商品类型: 'vip' | 'test_count' | 'single_test' | 'face' | 'mbti' 等 + * @param {Number} [options.testResultId] - 可选,对应 mbti_test_results.id,用于精确关联测试记录 + * @param {String} [options.deepProductId] - 可选,深度服务对应的套餐ID/产品Key,用于后台从 categories 中选中具体价格 + * @param {Function} options.success - 成功回调 + * @param {Function} options.fail - 失败回调 + */ +function wxPay(options) { + const { orderId, amount = 0, description, productType, testResultId, deepProductId, enterpriseId, success, fail } = options + + wx.showLoading({ + title: '正在支付...', + mask: true + }) + + // 1. 调用后端创建支付订单 + wx.request({ + url: `${app.globalData.apiBase}/api/payment/create`, + method: 'POST', + header: { + 'Authorization': `Bearer ${wx.getStorageSync('token')}`, + 'Content-Type': 'application/json' + }, + data: { + orderId, + amount, + description, + productType, + paymentMethod: 'wechat', + openId: app.globalData.openId || '', + enterpriseId: enterpriseId || 0, + // 创建订单时将本次测试记录ID传给后端,避免每次都只更新“最新一条” + testResultId: testResultId || 0, + // 深度服务使用的具体套餐ID/产品Key(用于从 categories 中选择价格) + deepProductId: deepProductId || '' + }, + success: (res) => { + wx.hideLoading() + + if (res.statusCode === 200 && res.data.code === 200) { + const paymentData = res.data.data + + // 2. 调起微信支付 + wx.requestPayment({ + timeStamp: paymentData.timeStamp, + nonceStr: paymentData.nonceStr, + package: paymentData.package, + signType: paymentData.signType || 'MD5', + paySign: paymentData.paySign, + success: (payRes) => { + console.log('支付成功', payRes) + + // 按照微信文档推荐,仅依赖查询接口确认支付结果,不做本地立即标记 + // 支付成功后轮询后端订单状态 3~5 次,确保通过微信订单查询接口确认成功 + pollOrderStatus(orderId, 5, 1000, (ok, order) => { + if (ok) { + wx.showToast({ + title: '支付成功', + icon: 'success', + duration: 2000 + }) + // 将本地订单信息一并透传给业务方 + success && success({ payRes, order }) + } else { + // 落库状态暂未确认,但微信侧已成功,一般稍后会自动对齐 + wx.showToast({ + title: '支付结果处理中,请稍后在历史中查看', + icon: 'none', + duration: 2500 + }) + success && success({ payRes, order: null }) + } + }) + }, + fail: (payErr) => { + console.error('支付失败', payErr) + + if (payErr.errMsg.indexOf('cancel') !== -1) { + wx.showToast({ + title: '支付已取消', + icon: 'none' + }) + } else { + wx.showToast({ + title: '支付失败', + icon: 'none' + }) + } + + fail && fail(payErr) + } + }) + } else { + wx.showToast({ + title: res.data.message || '创建订单失败', + icon: 'none' + }) + fail && fail(res) + } + }, + fail: (err) => { + wx.hideLoading() + console.error('请求失败', err) + + wx.showToast({ + title: '网络请求失败', + icon: 'none' + }) + + fail && fail(err) + } + }) +} + +/** + * 通知后端支付成功 + */ +function notifyPaymentSuccess(orderId, prepayId) { + wx.request({ + url: `${app.globalData.apiBase}/api/payment/notify`, + method: 'POST', + header: { + 'Authorization': `Bearer ${wx.getStorageSync('token')}`, + 'Content-Type': 'application/json' + }, + data: { + orderId, + prepayId, + status: 'success' + }, + success: (res) => { + console.log('支付通知成功', res) + }, + fail: (err) => { + console.error('支付通知失败', err) + } + }) +} + +/** + * 查询订单状态 + */ +function queryOrderStatus(orderId, callback) { + wx.request({ + url: `${app.globalData.apiBase}/api/payment/query`, + method: 'GET', + header: { + 'Authorization': `Bearer ${wx.getStorageSync('token')}` + }, + data: { orderId }, + success: (res) => { + if (res.statusCode === 200 && res.data.code === 200) { + callback && callback(true, res.data.data) + } else { + callback && callback(false, null) + } + }, + fail: () => { + callback && callback(false, null) + } + }) +} + +/** + * 按照微信文档建议:支付成功后,基于商户订单号轮询查询订单状态(本项目由后端代理查询) + * @param {String} orderId 商户订单号(如 FACE20260226151900001) + * @param {Number} maxAttempts 最大轮询次数 + * @param {Number} intervalMs 间隔毫秒 + * @param {Function} done 回调 (ok:boolean, order?:object) + */ +function pollOrderStatus(orderId, maxAttempts = 5, intervalMs = 1000, done) { + let attempts = 0 + + const tick = () => { + attempts += 1 + queryOrderStatus(orderId, (ok, order) => { + if (ok && order && (order.status === 'paid' || order.status === 'completed')) { + done && done(true, order) + return + } + if (attempts >= maxAttempts) { + done && done(false, order || null) + return + } + setTimeout(tick, intervalMs) + }) + } + + tick() +} + +/** + * 购买VIP会员 + * @param {String} vipType - 'month' | 'quarter' | 'year' | 'lifetime' + */ +function purchaseVIP(vipType, success, fail) { + const prices = { + month: 1990, // 19.9元 + quarter: 4990, // 49.9元 + year: 9900, // 99元 + lifetime: 19900, // 199元 + personal_insight: 19800 // 198元 - 个人深度洞察版 + } + + const names = { + month: '月度VIP会员', + quarter: '季度VIP会员', + year: '年度VIP会员', + lifetime: '终身VIP会员', + personal_insight: '个人深度洞察版' + } + + const orderId = generateOrderId('vip') + + wxPay({ + orderId, + amount: prices[vipType], + description: `MBTI性格测试 - ${names[vipType]}`, + productType: 'vip', + success: (res) => { + // 更新VIP状态 + updateVIPStatus(vipType) + success && success(res) + }, + fail + }) +} + +/** + * 购买测试次数 + * @param {Number} count - 购买次数 + */ +function purchaseTestCount(count, success, fail) { + // 单次价格 3.9元,10次29元,50次99元 + let price = count * 390 + if (count >= 10) price = Math.floor(count * 290) + if (count >= 50) price = Math.floor(count * 198) + + const orderId = generateOrderId('test_count') + + wxPay({ + orderId, + amount: price, + description: `MBTI性格测试 - ${count}次测试次数`, + productType: 'test_count', + success: (res) => { + // 更新测试次数 + addTestCount(count) + success && success(res) + }, + fail + }) +} + +/** + * 购买单次测试 + * @param {String} testType - 'mbti' | 'disc' | 'pdp' | 'ai' + */ +function purchaseSingleTest(testType, success, fail) { + const prices = { + mbti: 990, // 9.9元 + disc: 690, // 6.9元 + pdp: 690, // 6.9元 + ai: 1990 // 19.9元 + } + + const names = { + mbti: 'MBTI性格测试', + disc: 'DISC行为风格测试', + pdp: 'PDP动物性格测试', + ai: 'AI人脸性格分析' + } + + const orderId = generateOrderId(`single_${testType}`) + + wxPay({ + orderId, + amount: prices[testType], + description: names[testType], + productType: 'single_test', + success: (res) => { + // 解锁该测试 + unlockTest(testType) + success && success(res) + }, + fail + }) +} + +/** + * 使用后台定价购买单项测试/报告 + * 支持:人脸测试/MBTI测试/DISC测试/PDP测试/完整报告/团队分析 + * @param {String} productType - 'face' | 'mbti' | 'disc' | 'pdp' | 'report' | 'team_analysis' + * @param {String} description - 商品描述 + * @param {Function|Object} extra - 兼容两种调用方式: + * - purchaseByPricing('face', 'xxx', success, fail) + * - purchaseByPricing('face', 'xxx', { testResultId, success, fail }) + */ +function purchaseByPricing(productType, description, extra, maybeFail) { + let opts = {} + if (typeof extra === 'function' || extra === undefined) { + opts.success = extra + opts.fail = maybeFail + } else { + opts = extra || {} + } + + const { testResultId, success, fail } = opts + const orderId = generateOrderId(productType) + + wxPay({ + orderId, + amount: 0, // 金额交由后端根据定价配置计算(personal/enterprise) + description, + productType, + testResultId, + success, + fail + }) +} + +// 人脸测试完整报告 +// 兼容两种调用方式: +// - purchaseFaceTest(success, fail) +// - purchaseFaceTest({ testResultId, success, fail }) +function purchaseFaceTest(arg1, arg2) { + let opts = {} + if (typeof arg1 === 'function' || arg1 === undefined) { + opts.success = arg1 + opts.fail = arg2 + } else { + opts = arg1 || {} + } + + const { testResultId, success, fail } = opts + + purchaseByPricing('face', 'AI人脸性格分析完整报告', { testResultId, success, fail }) +} + +// MBTI测试付费版(支持 purchaseMbtiTest({ testResultId, success, fail })) +function purchaseMbtiTest(arg1, arg2) { + const opts = typeof arg1 === 'function' || arg1 == null ? { success: arg1, fail: arg2 } : (arg1 || {}) + const { testResultId, success, fail } = opts + purchaseByPricing('mbti', 'MBTI性格测试付费版', { testResultId, success, fail }) +} + +// DISC测试付费版 +function purchaseDiscTest(arg1, arg2) { + const opts = typeof arg1 === 'function' || arg1 == null ? { success: arg1, fail: arg2 } : (arg1 || {}) + const { testResultId, success, fail } = opts + purchaseByPricing('disc', 'DISC行为风格测试付费版', { testResultId, success, fail }) +} + +// PDP测试付费版 +function purchasePdpTest(arg1, arg2) { + const opts = typeof arg1 === 'function' || arg1 == null ? { success: arg1, fail: arg2 } : (arg1 || {}) + const { testResultId, success, fail } = opts + purchaseByPricing('pdp', 'PDP动物性格测试付费版', { testResultId, success, fail }) +} + +// 简历综合分析付费版(支持 purchaseResumeAnalysis({ testResultId, success, fail })) +function purchaseResumeAnalysis(arg1, arg2) { + const opts = typeof arg1 === 'function' || arg1 == null ? { success: arg1, fail: arg2 } : (arg1 || {}) + const { testResultId, success, fail } = opts + purchaseByPricing('resume', '简历综合分析付费版', { testResultId, success, fail }) +} + +// 完整报告(整合MBTI/DISC/PDP/AI人脸等深度解读) +function purchaseFullReport(success, fail) { + purchaseByPricing('report', '完整人格与职业发展报告', success, fail) +} + +// 团队分析服务 +function purchaseTeamAnalysis(success, fail) { + purchaseByPricing('team_analysis', '团队性格组合与冲突分析服务', success, fail) +} + +/** + * 企业充值 + * 支持两种调用: + * - recharge(100, success, fail) + * - recharge({ amountYuan: 100, enterpriseId: 6, success, fail }) + */ +function recharge(arg1, arg2, arg3) { + const opts = (typeof arg1 === 'object' && arg1 !== null) + ? arg1 + : { amountYuan: arg1, success: arg2, fail: arg3 } + const safeAmount = Number(opts.amountYuan) || 0 + const amount = Math.round(safeAmount * 100) + const orderId = generateOrderId('recharge') + + wxPay({ + orderId, + amount, + description: `账户充值 ¥${safeAmount.toFixed(2)}`, + productType: 'recharge', + enterpriseId: opts.enterpriseId || 0, + success: opts.success, + fail: opts.fail + }) +} + +/** + * 个人深度服务(价格从 PricingConfig deep_personal.categories 中读取) + * 支持两种调用方式: + * - purchasePersonalDeepService(deepProductId, success, fail) + * - purchasePersonalDeepService({ deepProductId, description, success, fail }) + * @param {String|Object} arg1 + * @param {Function} [arg2] + * @param {Function} [arg3] + */ +function purchasePersonalDeepService(arg1, arg2, arg3) { + let deepProductId = '' + let description = '' + let success + let fail + + if (typeof arg1 === 'object' && arg1 !== null) { + deepProductId = arg1.deepProductId || '' + description = arg1.description || '' + success = arg1.success + fail = arg1.fail + } else { + deepProductId = typeof arg1 === 'string' ? arg1 : '' + success = arg2 + fail = arg3 + } + + // 默认标题兜底 + const desc = description || '个人深度服务(1v1深度解读)' + const orderId = generateOrderId('deep_personal') + + wxPay({ + orderId, + amount: 0, + description: desc, + productType: 'deep_personal', + deepProductId, + success, + fail + }) +} + +/** + * 团队深度服务(价格从 PricingConfig.deep.team 读取) + */ +function purchaseTeamDeepService(success, fail) { + const orderId = generateOrderId('deep_team') + + wxPay({ + orderId, + amount: 0, + description: '团队深度服务(团队画像+策略)', + productType: 'deep_team', + success, + fail + }) +} + +/** + * 更新VIP状态 + */ +function updateVIPStatus(vipType) { + const durations = { + month: 30, + quarter: 90, + year: 365, + lifetime: 36500 // 100年 + } + + const expireDate = new Date() + expireDate.setDate(expireDate.getDate() + durations[vipType]) + + const vipInfo = { + isVIP: true, + vipType, + expireDate: expireDate.toISOString(), + purchaseDate: new Date().toISOString() + } + + wx.setStorageSync('vipInfo', vipInfo) + app.globalData.vipInfo = vipInfo +} + +/** + * 增加测试次数 + */ +function addTestCount(count) { + const currentCount = wx.getStorageSync('testCount') || 0 + const newCount = currentCount + count + wx.setStorageSync('testCount', newCount) + app.globalData.testCount = newCount +} + +/** + * 解锁单次测试 + */ +function unlockTest(testType) { + const unlockedTests = wx.getStorageSync('unlockedTests') || [] + if (!unlockedTests.includes(testType)) { + unlockedTests.push(testType) + wx.setStorageSync('unlockedTests', unlockedTests) + } + app.globalData.unlockedTests = unlockedTests +} + +/** + * 检查是否是VIP + */ +function checkVIP() { + const vipInfo = wx.getStorageSync('vipInfo') + if (!vipInfo || !vipInfo.isVIP) return false + + const expireDate = new Date(vipInfo.expireDate) + return expireDate > new Date() +} + +/** + * 检查测试是否可用(VIP或已解锁或有测试次数) + */ +function canTakeTest(testType) { + // 临时策略:所有测试免费开放,直接返回 true + // 保留原有支付与权益逻辑,后续若恢复收费可还原为 VIP/解锁/次数判断 + return true +} + +/** + * 消耗一次测试次数 + */ +function consumeTestCount() { + const testCount = wx.getStorageSync('testCount') || 0 + if (testCount > 0) { + wx.setStorageSync('testCount', testCount - 1) + app.globalData.testCount = testCount - 1 + return true + } + return false +} + +/** + * 获取用户权益信息 + */ +function getUserBenefits() { + return { + isVIP: checkVIP(), + vipInfo: wx.getStorageSync('vipInfo') || null, + testCount: wx.getStorageSync('testCount') || 0, + unlockedTests: wx.getStorageSync('unlockedTests') || [] + } +} + +module.exports = { + wxPay, + queryOrderStatus, + pollOrderStatus, + generateOrderId, + purchaseVIP, + purchaseTestCount, + purchaseSingleTest, + // 基于后台定价的购买入口(测试/报告/团队分析/深度服务/充值) + purchaseFaceTest, + purchaseMbtiTest, + purchaseDiscTest, + purchasePdpTest, + purchaseResumeAnalysis, + purchaseFullReport, + purchaseTeamAnalysis, + recharge, + purchasePersonalDeepService, + purchaseTeamDeepService, + checkVIP, + canTakeTest, + consumeTestCount, + getUserBenefits, + updateVIPStatus, + addTestCount, + unlockTest +} diff --git a/miniprogram/utils/phoneAuth.js b/miniprogram/utils/phoneAuth.js new file mode 100644 index 0000000..9108fd9 --- /dev/null +++ b/miniprogram/utils/phoneAuth.js @@ -0,0 +1,113 @@ +/** + * 手机号授权工具:基于微信 getPhoneNumber + 服务器端换取手机号接口 + * 个人资料完整性检查:头像、昵称、手机号为必填,生日和性别选填 + */ + +/** + * 个人资料是否已完善(头像、昵称、手机号必填,生日和性别选填) + * @returns {boolean} + */ +function isProfileComplete() { + const app = getApp() + const user = app.globalData.userInfo || wx.getStorageSync('userInfo') + if (!user) return false + const avatar = (user.avatar || user.avatarUrl || '').trim() + const nickname = (user.nickname || user.nickName || '').trim() + const phone = (user.phone || user.phoneNumber || '').trim() + return avatar.length > 0 && nickname.length > 0 && phone.length > 0 +} + +/** + * 若资料未完善则跳转到个人资料页,需登录 + * @returns {boolean} true=已完善可继续,false=已跳转 + */ +function ensureProfileCompleteAndRedirect() { + const app = getApp() + const token = app.globalData.token || wx.getStorageSync('token') + if (!token) return true + if (isProfileComplete()) return true + wx.showToast({ title: '请先完善个人资料', icon: 'none' }) + wx.navigateTo({ url: '/pages/user-profile/index' }) + return false +} + +/** + * 当前用户是否已有手机号(从 globalData.userInfo 或 storage 读取) + * @returns {boolean} + */ +function hasPhone() { + const app = getApp() + const user = app.globalData.userInfo || wx.getStorageSync('userInfo') + const phone = (user && (user.phone || user.phoneNumber)) ? String(user.phone || user.phoneNumber).trim() : '' + return phone.length > 0 +} + +/** + * 使用 getPhoneNumber 回调里的 code 调用后端接口换取手机号,并写回 userInfo + * @param {string} code + * @returns {Promise} resolve 为更新后的 userInfo + */ +function bindPhoneByCode(code) { + return new Promise((resolve, reject) => { + if (!code) { + wx.showToast({ title: '获取手机号失败', icon: 'none' }) + reject(new Error('empty code')) + return + } + + const app = getApp() + const token = app.globalData.token || wx.getStorageSync('token') + if (!token) { + wx.showToast({ title: '请先登录', icon: 'none' }) + reject(new Error('no token')) + return + } + + const apiBase = app.globalData.apiBase || '' + if (!apiBase) { + wx.showToast({ title: '服务未配置', icon: 'none' }) + reject(new Error('no api base')) + return + } + + wx.showLoading({ title: '处理中...', mask: true }) + wx.request({ + url: `${apiBase.replace(/\/$/, '')}/api/auth/wechat/phone`, + method: 'POST', + header: { + 'Authorization': 'Bearer ' + token, + 'Content-Type': 'application/json', + }, + data: { code }, + success: (res) => { + wx.hideLoading() + if (res.statusCode === 200 && res.data && res.data.code === 200) { + const data = res.data.data || {} + const user = data.user || app.globalData.userInfo || {} + const phone = data.phone || user.phone || '' + const newUser = { ...user, phone } + app.globalData.userInfo = newUser + wx.setStorageSync('userInfo', newUser) + wx.showToast({ title: '授权成功', icon: 'success' }) + resolve(newUser) + } else { + const msg = res.data && res.data.message ? res.data.message : '获取手机号失败' + wx.showToast({ title: msg, icon: 'none' }) + reject(new Error(msg)) + } + }, + fail: () => { + wx.hideLoading() + wx.showToast({ title: '网络请求失败', icon: 'none' }) + reject(new Error('network error')) + }, + }) + }) +} + +module.exports = { + hasPhone, + bindPhoneByCode, + isProfileComplete, + ensureProfileCompleteAndRedirect, +} diff --git a/miniprogram/utils/questions.js b/miniprogram/utils/questions.js new file mode 100644 index 0000000..161866d --- /dev/null +++ b/miniprogram/utils/questions.js @@ -0,0 +1,117 @@ +// utils/questions.js - 测试题库 + +// MBTI测试题(精简版30题,每个维度约7-8题) +const mbtiQuestions = [ + // E vs I 维度 (8题) + { id: 1, question: "在社交场合中,您通常会:", dimension: "EI", options: [{ value: "E", text: "认识新朋友,扩大社交圈" }, { value: "I", text: "与已认识的朋友交流" }] }, + { id: 2, question: "您更喜欢:", dimension: "EI", options: [{ value: "E", text: "在团队中工作" }, { value: "I", text: "独立工作" }] }, + { id: 3, question: "当您需要充电时,您会选择:", dimension: "EI", options: [{ value: "E", text: "与朋友聚会" }, { value: "I", text: "独处休息" }] }, + { id: 4, question: "在会议中,您通常:", dimension: "EI", options: [{ value: "E", text: "积极发言" }, { value: "I", text: "先思考再发表" }] }, + { id: 5, question: "您更倾向于:", dimension: "EI", options: [{ value: "E", text: "认识很多人" }, { value: "I", text: "与少数人深交" }] }, + { id: 6, question: "面对问题时,您更倾向于:", dimension: "EI", options: [{ value: "E", text: "与他人讨论" }, { value: "I", text: "独自思考" }] }, + { id: 7, question: "您更喜欢的休闲活动是:", dimension: "EI", options: [{ value: "E", text: "团体活动" }, { value: "I", text: "个人爱好" }] }, + { id: 8, question: "在陌生环境中,您通常会:", dimension: "EI", options: [{ value: "E", text: "主动与人交谈" }, { value: "I", text: "观察周围环境" }] }, + + // S vs N 维度 (7题) + { id: 9, question: "您更关注:", dimension: "SN", options: [{ value: "S", text: "具体的细节和事实" }, { value: "N", text: "整体概念和可能性" }] }, + { id: 10, question: "您更信任:", dimension: "SN", options: [{ value: "S", text: "实际经验" }, { value: "N", text: "直觉和灵感" }] }, + { id: 11, question: "您更喜欢:", dimension: "SN", options: [{ value: "S", text: "按步骤执行" }, { value: "N", text: "创造性解决" }] }, + { id: 12, question: "学习新事物时,您更喜欢:", dimension: "SN", options: [{ value: "S", text: "实际操作" }, { value: "N", text: "理论学习" }] }, + { id: 13, question: "您更喜欢的工作类型是:", dimension: "SN", options: [{ value: "S", text: "明确的任务" }, { value: "N", text: "创新的项目" }] }, + { id: 14, question: "您描述事物时更倾向于:", dimension: "SN", options: [{ value: "S", text: "具体描述" }, { value: "N", text: "使用比喻" }] }, + { id: 15, question: "您更关心:", dimension: "SN", options: [{ value: "S", text: "当下的现实" }, { value: "N", text: "未来的可能" }] }, + + // T vs F 维度 (8题) + { id: 16, question: "做决定时,您更依赖:", dimension: "TF", options: [{ value: "T", text: "逻辑分析" }, { value: "F", text: "个人价值" }] }, + { id: 17, question: "在争论中,您更看重:", dimension: "TF", options: [{ value: "T", text: "事实真相" }, { value: "F", text: "和谐关系" }] }, + { id: 18, question: "给予反馈时,您更注重:", dimension: "TF", options: [{ value: "T", text: "直接指出问题" }, { value: "F", text: "考虑对方感受" }] }, + { id: 19, question: "评估方案时,您更关注:", dimension: "TF", options: [{ value: "T", text: "效率和结果" }, { value: "F", text: "对人的影响" }] }, + { id: 20, question: "当朋友遇到困难时,您更倾向于:", dimension: "TF", options: [{ value: "T", text: "分析问题提供建议" }, { value: "F", text: "倾听并给予支持" }] }, + { id: 21, question: "您更欣赏的品质是:", dimension: "TF", options: [{ value: "T", text: "理性客观" }, { value: "F", text: "善解人意" }] }, + { id: 22, question: "您认为好的决定应该:", dimension: "TF", options: [{ value: "T", text: "基于客观分析" }, { value: "F", text: "考虑各方感受" }] }, + { id: 23, question: "当与他人意见不同时,您更倾向于:", dimension: "TF", options: [{ value: "T", text: "坚持正确观点" }, { value: "F", text: "寻求共识" }] }, + + // J vs P 维度 (7题) + { id: 24, question: "您更喜欢的工作方式是:", dimension: "JP", options: [{ value: "J", text: "有计划地进行" }, { value: "P", text: "随机应变" }] }, + { id: 25, question: "对于截止日期,您通常:", dimension: "JP", options: [{ value: "J", text: "提前完成" }, { value: "P", text: "在最后完成" }] }, + { id: 26, question: "您的生活方式更倾向于:", dimension: "JP", options: [{ value: "J", text: "有条理有规律" }, { value: "P", text: "灵活随意" }] }, + { id: 27, question: "面对选择时,您更倾向于:", dimension: "JP", options: [{ value: "J", text: "快速做出决定" }, { value: "P", text: "保持开放选项" }] }, + { id: 28, question: "您的桌面通常是:", dimension: "JP", options: [{ value: "J", text: "整洁有序" }, { value: "P", text: "创意性混乱" }] }, + { id: 29, question: "计划改变时,您的反应是:", dimension: "JP", options: [{ value: "J", text: "感到不安" }, { value: "P", text: "觉得有趣" }] }, + { id: 30, question: "您更喜欢:", dimension: "JP", options: [{ value: "J", text: "事先规划" }, { value: "P", text: "即兴发挥" }] } +] + +// DISC测试题 (20题) +const discQuestions = [ + { id: 1, question: "在团队中,您更倾向于:", options: [{ value: "D", text: "主导决策,带领团队" }, { value: "I", text: "活跃气氛,激励成员" }, { value: "S", text: "支持他人,确保和谐" }, { value: "C", text: "分析数据,确保质量" }] }, + { id: 2, question: "面对挑战时,您的第一反应是:", options: [{ value: "D", text: "立即行动" }, { value: "I", text: "寻找支持" }, { value: "S", text: "冷静思考" }, { value: "C", text: "收集信息" }] }, + { id: 3, question: "您在工作中最看重的是:", options: [{ value: "D", text: "成果和效率" }, { value: "I", text: "认可和赞赏" }, { value: "S", text: "稳定和安全" }, { value: "C", text: "准确和质量" }] }, + { id: 4, question: "与他人沟通时,您通常:", options: [{ value: "D", text: "直接了当" }, { value: "I", text: "热情友好" }, { value: "S", text: "耐心倾听" }, { value: "C", text: "逻辑清晰" }] }, + { id: 5, question: "压力之下,您会:", options: [{ value: "D", text: "更加强势" }, { value: "I", text: "寻求鼓励" }, { value: "S", text: "保持冷静" }, { value: "C", text: "更加谨慎" }] }, + { id: 6, question: "您认为自己的优势是:", options: [{ value: "D", text: "决断力强" }, { value: "I", text: "人际关系好" }, { value: "S", text: "可靠稳定" }, { value: "C", text: "分析能力强" }] }, + { id: 7, question: "在会议中,您通常扮演:", options: [{ value: "D", text: "主导者" }, { value: "I", text: "激励者" }, { value: "S", text: "调和者" }, { value: "C", text: "分析者" }] }, + { id: 8, question: "您最不喜欢的工作环境是:", options: [{ value: "D", text: "进展缓慢" }, { value: "I", text: "被孤立" }, { value: "S", text: "变化太快" }, { value: "C", text: "混乱无序" }] }, + { id: 9, question: "做决定时,您更依赖:", options: [{ value: "D", text: "直觉经验" }, { value: "I", text: "他人意见" }, { value: "S", text: "过去经验" }, { value: "C", text: "数据事实" }] }, + { id: 10, question: "您的工作风格是:", options: [{ value: "D", text: "快速高效" }, { value: "I", text: "灵活多变" }, { value: "S", text: "稳定持续" }, { value: "C", text: "严谨细致" }] }, + { id: 11, question: "遇到冲突时,您会:", options: [{ value: "D", text: "直面解决" }, { value: "I", text: "调解双方" }, { value: "S", text: "避免冲突" }, { value: "C", text: "分析原因" }] }, + { id: 12, question: "您期望的领导风格是:", options: [{ value: "D", text: "给予挑战" }, { value: "I", text: "认可表扬" }, { value: "S", text: "稳定支持" }, { value: "C", text: "明确指导" }] }, + { id: 13, question: "处理任务时,您更注重:", options: [{ value: "D", text: "速度效率" }, { value: "I", text: "创意新颖" }, { value: "S", text: "过程协作" }, { value: "C", text: "质量准确" }] }, + { id: 14, question: "您的社交方式是:", options: [{ value: "D", text: "目的明确" }, { value: "I", text: "广泛社交" }, { value: "S", text: "深度交往" }, { value: "C", text: "选择性社交" }] }, + { id: 15, question: "您理想的工作节奏是:", options: [{ value: "D", text: "快节奏" }, { value: "I", text: "灵活多变" }, { value: "S", text: "稳定有序" }, { value: "C", text: "有条理" }] }, + { id: 16, question: "面对变化,您的态度是:", options: [{ value: "D", text: "主动拥抱" }, { value: "I", text: "积极适应" }, { value: "S", text: "需要时间" }, { value: "C", text: "谨慎评估" }] }, + { id: 17, question: "您的时间管理风格是:", options: [{ value: "D", text: "高效利用" }, { value: "I", text: "灵活安排" }, { value: "S", text: "按部就班" }, { value: "C", text: "精确规划" }] }, + { id: 18, question: "激励您的是:", options: [{ value: "D", text: "成就控制" }, { value: "I", text: "认可社交" }, { value: "S", text: "稳定归属" }, { value: "C", text: "正确标准" }] }, + { id: 19, question: "您处理细节的方式是:", options: [{ value: "D", text: "关注大局" }, { value: "I", text: "可能忽略" }, { value: "S", text: "认真对待" }, { value: "C", text: "极度重视" }] }, + { id: 20, question: "您对规则的态度是:", options: [{ value: "D", text: "灵活打破" }, { value: "I", text: "灵活运用" }, { value: "S", text: "遵守维护" }, { value: "C", text: "严格遵守" }] } +] + +// PDP测试题 (20题) +const pdpQuestions = [ + { id: 1, question: "面对紧急任务,您的第一反应是:", options: [{ value: "Tiger", text: "立即行动" }, { value: "Peacock", text: "召集团队" }, { value: "Koala", text: "冷静分析" }, { value: "Owl", text: "仔细规划" }, { value: "Chameleon", text: "灵活应对" }] }, + { id: 2, question: "在社交场合,您通常会:", options: [{ value: "Tiger", text: "主导话题" }, { value: "Peacock", text: "活跃气氛" }, { value: "Koala", text: "安静倾听" }, { value: "Owl", text: "观察分析" }, { value: "Chameleon", text: "根据对象调整" }] }, + { id: 3, question: "您最看重工作中的:", options: [{ value: "Tiger", text: "权力和成就" }, { value: "Peacock", text: "认可和赞赏" }, { value: "Koala", text: "稳定和和谐" }, { value: "Owl", text: "准确和质量" }, { value: "Chameleon", text: "平衡和适应" }] }, + { id: 4, question: "处理冲突时,您倾向于:", options: [{ value: "Tiger", text: "直接解决" }, { value: "Peacock", text: "调解双方" }, { value: "Koala", text: "避免冲突" }, { value: "Owl", text: "理性处理" }, { value: "Chameleon", text: "视情况而定" }] }, + { id: 5, question: "您的决策风格是:", options: [{ value: "Tiger", text: "果断迅速" }, { value: "Peacock", text: "直觉判断" }, { value: "Koala", text: "深思熟虑" }, { value: "Owl", text: "数据分析" }, { value: "Chameleon", text: "灵活决策" }] }, + { id: 6, question: "面对压力,您会:", options: [{ value: "Tiger", text: "更加强势" }, { value: "Peacock", text: "寻求支持" }, { value: "Koala", text: "保持冷静" }, { value: "Owl", text: "更加谨慎" }, { value: "Chameleon", text: "调整策略" }] }, + { id: 7, question: "您的领导风格是:", options: [{ value: "Tiger", text: "指挥型" }, { value: "Peacock", text: "激励型" }, { value: "Koala", text: "支持型" }, { value: "Owl", text: "专家型" }, { value: "Chameleon", text: "教练型" }] }, + { id: 8, question: "您处理细节的方式是:", options: [{ value: "Tiger", text: "关注大局" }, { value: "Peacock", text: "可能忽略" }, { value: "Koala", text: "认真对待" }, { value: "Owl", text: "极度重视" }, { value: "Chameleon", text: "视情况决定" }] }, + { id: 9, question: "您的沟通方式是:", options: [{ value: "Tiger", text: "直接简短" }, { value: "Peacock", text: "热情生动" }, { value: "Koala", text: "温和耐心" }, { value: "Owl", text: "逻辑清晰" }, { value: "Chameleon", text: "根据对象调整" }] }, + { id: 10, question: "您对变化的态度是:", options: [{ value: "Tiger", text: "主动推动" }, { value: "Peacock", text: "积极拥抱" }, { value: "Koala", text: "需要适应" }, { value: "Owl", text: "谨慎评估" }, { value: "Chameleon", text: "随机应变" }] }, + { id: 11, question: "您的时间管理风格是:", options: [{ value: "Tiger", text: "追求速度" }, { value: "Peacock", text: "灵活安排" }, { value: "Koala", text: "稳定执行" }, { value: "Owl", text: "精确规划" }, { value: "Chameleon", text: "根据情况调整" }] }, + { id: 12, question: "您被什么激励:", options: [{ value: "Tiger", text: "成就权力" }, { value: "Peacock", text: "认可赞赏" }, { value: "Koala", text: "安全归属" }, { value: "Owl", text: "正确标准" }, { value: "Chameleon", text: "多样平衡" }] }, + { id: 13, question: "您的学习方式是:", options: [{ value: "Tiger", text: "边做边学" }, { value: "Peacock", text: "互动讨论" }, { value: "Koala", text: "循序渐进" }, { value: "Owl", text: "深入研究" }, { value: "Chameleon", text: "多种结合" }] }, + { id: 14, question: "您对规则的态度是:", options: [{ value: "Tiger", text: "灵活打破" }, { value: "Peacock", text: "不拘一格" }, { value: "Koala", text: "遵守维护" }, { value: "Owl", text: "严格遵守" }, { value: "Chameleon", text: "灵活处理" }] }, + { id: 15, question: "您在团队中的角色是:", options: [{ value: "Tiger", text: "领导者" }, { value: "Peacock", text: "激励者" }, { value: "Koala", text: "协调者" }, { value: "Owl", text: "专家" }, { value: "Chameleon", text: "多面手" }] }, + { id: 16, question: "您的工作节奏是:", options: [{ value: "Tiger", text: "快节奏" }, { value: "Peacock", text: "充满活力" }, { value: "Koala", text: "稳定有序" }, { value: "Owl", text: "有条理" }, { value: "Chameleon", text: "灵活调整" }] }, + { id: 17, question: "您最大的优势是:", options: [{ value: "Tiger", text: "执行力" }, { value: "Peacock", text: "影响力" }, { value: "Koala", text: "可靠性" }, { value: "Owl", text: "准确性" }, { value: "Chameleon", text: "适应力" }] }, + { id: 18, question: "您的人际关系特点是:", options: [{ value: "Tiger", text: "目标导向" }, { value: "Peacock", text: "朋友众多" }, { value: "Koala", text: "关系稳定" }, { value: "Owl", text: "志同道合" }, { value: "Chameleon", text: "灵活建立" }] }, + { id: 19, question: "面对批评,您的反应是:", options: [{ value: "Tiger", text: "可能反驳" }, { value: "Peacock", text: "可能受伤" }, { value: "Koala", text: "接受思考" }, { value: "Owl", text: "分析合理性" }, { value: "Chameleon", text: "灵活调整" }] }, + { id: 20, question: "您的理想工作环境是:", options: [{ value: "Tiger", text: "充满挑战" }, { value: "Peacock", text: "互动频繁" }, { value: "Koala", text: "稳定和谐" }, { value: "Owl", text: "有序规范" }, { value: "Chameleon", text: "灵活多变" }] } +] + +/** + * Fisher-Yates 洗牌:随机打乱题目顺序,同时随机打乱每题的选项顺序 + * 不修改原数组,返回深拷贝后的新数组 + * @param {Array} questions - 原题目数组 + * @returns {Array} 打乱后的题目数组 + */ +function shuffleQuestions(questions) { + // 深拷贝,避免污染原始数组 + const arr = questions.map(q => ({ + ...q, + options: q.options.slice().sort(() => Math.random() - 0.5) + })) + // Fisher-Yates 打乱题目顺序 + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]] + } + return arr +} + +module.exports = { + mbtiQuestions, + discQuestions, + pdpQuestions, + shuffleQuestions +} diff --git a/miniprogram/utils/request.js b/miniprogram/utils/request.js new file mode 100644 index 0000000..6c53b9b --- /dev/null +++ b/miniprogram/utils/request.js @@ -0,0 +1,109 @@ +/** + * 统一请求封装:自动拼接 baseURL、携带 token、处理 401 + * 使用前需已执行 App(),否则 getApp() 在 require 时可能未就绪,这里在请求时再取 app + */ +function getAppSafe() { + try { + return getApp() + } catch (e) { + return null + } +} + +function getToken() { + const app = getAppSafe() + return (app && app.globalData && app.globalData.token) || wx.getStorageSync('token') || '' +} + +function getApiBase() { + const app = getAppSafe() + return (app && app.globalData && app.globalData.apiBase) || '' +} + +/** + * 清除登录态(401 或主动退出时调用) + */ +function clearLoginState() { + const app = getAppSafe() + if (app && app.globalData) { + app.globalData.token = null + app.globalData.userInfo = null + app.globalData.openId = null + } + try { + wx.removeStorageSync('token') + wx.removeStorageSync('userInfo') + } catch (e) {} +} + +/** + * 发起请求 + * @param {Object} options - 同 wx.request,url 可为相对路径(自动加 apiBase) + * @param {boolean} options.needAuth - 是否携带 Authorization,默认 true + * @param {boolean} options.allow401 - 401 时是否静默清除登录态而不 fail,默认 true + */ +function request(options) { + const apiBase = getApiBase() + const url = options.url + const fullUrl = url.startsWith('http') ? url : `${apiBase.replace(/\/$/, '')}${url.startsWith('/') ? '' : '/'}${url}` + const needAuth = options.needAuth !== false + const allow401 = options.allow401 !== false + + const header = { + 'Content-Type': 'application/json', + ...(options.header || {}) + } + if (needAuth) { + const token = getToken() + if (token) header['Authorization'] = `Bearer ${token}` + } + + const success = options.success + const fail = options.fail + const complete = options.complete + + return wx.request({ + ...options, + url: fullUrl, + header, + success(res) { + if (res.statusCode === 401 && allow401) { + clearLoginState() + } + if (success) success(res) + }, + fail(err) { + if (fail) fail(err) + }, + complete(res) { + if (complete) complete(res) + } + }) +} + +/** + * Promise 版 request,便于 async/await + */ +function requestPromise(options) { + return new Promise((resolve, reject) => { + request({ + ...options, + success(res) { + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve(res) + } else { + reject(new Error(res.data && res.data.message ? res.data.message : '请求失败')) + } + }, + fail: reject + }) + }) +} + +module.exports = { + request, + requestPromise, + getToken, + getApiBase, + clearLoginState +} diff --git a/miniprogram/utils/resultFormat.js b/miniprogram/utils/resultFormat.js new file mode 100644 index 0000000..a8976fe --- /dev/null +++ b/miniprogram/utils/resultFormat.js @@ -0,0 +1,95 @@ +/** + * 测试结果摘要格式化:类型 + 整数百分比(与管理后台一致) + */ + +function toIntPercent(value) { + if (value == null) return 0 + const n = typeof value === 'number' ? value : Number(value) + return Number.isFinite(n) ? Math.round(n) : 0 +} + +/** + * 根据测试类型和原始结果,生成带整数百分比的摘要文案 + * @param {object} data - 单条测试结果(mbtiResult / discResult / pdpResult) + * @param {string} testType - 'mbti' | 'disc' | 'pdp' + * @returns {string} + */ +function formatTestSummary(data, testType) { + if (!data || typeof data !== 'object') return '' + const t = (testType || '').toLowerCase() + + if (t === 'mbti') { + const label = data.mbtiType || data.type || data.result || '' + const dims = data.dimensionScores + if (dims && typeof dims === 'object') { + const parts = [] + const order = ['EI', 'SN', 'TF', 'JP'] + for (const key of order) { + const pct = dims[key] && (dims[key].percentage != null ? dims[key].percentage : dims[key].dominant) + if (pct != null) parts.push(toIntPercent(pct) + '%') + } + if (parts.length) return label + ' (' + parts.join(' ') + ')' + } + return String(label) + } + + if (t === 'disc') { + const desc = data.description && data.description.type + const label = (typeof desc === 'string' && desc) ? desc : ((data.dominantType ? data.dominantType + '型' : '') || (data.disc || '')) + const pct = data.percentages + if (pct && typeof pct === 'object') { + const d = toIntPercent(pct.D != null ? pct.D : pct.d) + const i = toIntPercent(pct.I != null ? pct.I : pct.i) + const s = toIntPercent(pct.S != null ? pct.S : pct.s) + const c = toIntPercent(pct.C != null ? pct.C : pct.c) + return label + ' D:' + d + '% I:' + i + '% S:' + s + '% C:' + c + '%' + } + return label || '' + } + + if (t === 'pdp') { + const desc = data.description && data.description.type + const label = (typeof desc === 'string' && desc) ? desc : (data.dominantType || data.pdp || '') + const pct = data.percentages + if (pct && typeof pct === 'object') { + const names = { Tiger: '老虎', Peacock: '孔雀', Owl: '猫头鹰', Koala: '考拉', Chameleon: '变色龙' } + const parts = [] + for (const [key, name] of Object.entries(names)) { + const v = pct[key] != null ? pct[key] : pct[key.toLowerCase()] + if (v != null) parts.push(name + ':' + toIntPercent(v) + '%') + } + if (parts.length) return label + ' ' + parts.join(' ') + } + return label || '' + } + + return '' +} + +/** + * 仅返回类型标签(无百分比),用于列表、个人中心等 + */ +function getTypeOnly(data, testType) { + if (!data || typeof data !== 'object') return '' + const t = (testType || '').toLowerCase() + if (t === 'mbti') return String(data.mbtiType ?? data.type ?? data.result ?? '') + if (t === 'disc') { + const desc = data.description?.type + if (typeof desc === 'string' && desc) return desc + if (data.dominantType) return String(data.dominantType) + '型' + return String(data.disc ?? '') + } + if (t === 'pdp') { + const desc = data.description?.type + if (typeof desc === 'string' && desc) return desc + if (data.dominantType) return String(data.dominantType) + return String(data.pdp ?? '') + } + return '' +} + +module.exports = { + toIntPercent, + formatTestSummary, + getTypeOnly +} diff --git a/miniprogram/utils/share.js b/miniprogram/utils/share.js new file mode 100644 index 0000000..0be4071 --- /dev/null +++ b/miniprogram/utils/share.js @@ -0,0 +1,60 @@ +/** + * 分享参数工具 + * - uid:当前登录用户 ID(始终携带,用于邀请统计) + * - eid:企业 ID(仅 appScope=enterprise 时携带) + * + * 小程序好友分享(onShareAppMessage):path 带完整查询串 + * 朋友圈分享(onShareTimeline):返回 query 字符串(不含 ?) + * 接收方(落地页 onLoad options)直接读 options.uid / options.eid + */ + +function getApp_() { + try { return getApp() } catch (e) { return null } +} + +/** + * 构建分享查询串,例如 "uid=1&eid=6" 或 "uid=1" + */ +function buildShareQuery() { + try { + const app = getApp_() + const userInfo = (app && app.globalData && app.globalData.userInfo) || wx.getStorageSync('userInfo') || {} + const scope = (app && app.globalData && app.globalData.appScope) || 'personal' + const uid = userInfo.id || '' + const parts = [] + if (uid) parts.push('uid=' + uid) + if (scope === 'enterprise') { + const eid = (app && app.globalData && app.globalData.enterpriseIdFromScene) + || userInfo.enterpriseId + || '' + if (eid) parts.push('eid=' + eid) + } + return parts.join('&') + } catch (e) { + return '' + } +} + +/** + * 返回带查询参数的完整落地页路径 + * @param {string} basePath 例如 '/pages/index/index' + * @param {boolean} forceEnterprise 强制使用企业版落地页(传 true 时 basePath 无效) + */ +function getSharePath(basePath) { + const query = buildShareQuery() + return query ? basePath + '?' + query : basePath +} + +/** + * 根据当前 scope 决定落地页路径 + * - 企业版 → /pages/enterprise/index?uid=X&eid=Y + * - 个人版 → basePath?uid=X (basePath 默认 /pages/index/index) + */ +function getSharePathByScope(personalBasePath) { + const app = getApp_() + const scope = (app && app.globalData && app.globalData.appScope) || 'personal' + const base = scope === 'enterprise' ? '/pages/enterprise/index' : (personalBasePath || '/pages/index/index') + return getSharePath(base) +} + +module.exports = { buildShareQuery, getSharePath, getSharePathByScope }