chore: 首次提交 - 关联 GitHub fnvtk/MBTI_wang

Made-with: Cursor
This commit is contained in:
卡若
2026-03-17 12:39:38 +08:00
commit eb510304c0
259 changed files with 60464 additions and 0 deletions

82
miniprogram/README.md Normal file
View File

@@ -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

344
miniprogram/app.js Normal file
View File

@@ -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 解析出的企业IDe_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<boolean>} 是否登录成功(拿到 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<boolean>} 当前是否有有效登录态
*/
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
})
})
}
})

64
miniprogram/app.json Normal file
View File

@@ -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"
}

176
miniprogram/app.wxss Normal file
View File

@@ -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);
}

View File

@@ -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 })
}
}
})

View File

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

View File

@@ -0,0 +1,35 @@
<!-- 自定义 tabBar顶部分割线 + 三栏,中间浮起圆钮 -->
<view class="tab-bar">
<view class="tab-bar-line"></view>
<view class="tab-bar-inner">
<view
class="tab-item {{selected === 0 ? 'active' : ''}}"
data-index="0"
data-path="/pages/index/index"
bindtap="switchTab"
>
<image class="tab-icon" src="{{selected === 0 ? '/images/home-active.png' : '/images/home.png'}}" mode="aspectFit" />
<text class="tab-text">首页</text>
</view>
<view
class="tab-item-center {{selected === 1 ? 'active' : ''}}"
data-index="1"
data-path="/pages/index/camera"
bindtap="switchTab"
>
<view class="center-circle">
<image class="center-icon" src="{{selected === 1 ? '/images/camera-active.png' : '/images/camera.png'}}" mode="aspectFit" />
</view>
<view class="center-text-placeholder"></view>
</view>
<view
class="tab-item {{selected === 2 ? 'active' : ''}}"
data-index="2"
data-path="/pages/profile/index"
bindtap="switchTab"
>
<image class="tab-icon" src="{{selected === 2 ? '/images/user-active.png' : '/images/user.png'}}" mode="aspectFit" />
<text class="tab-text">我的</text>
</view>
</view>
</view>

View File

@@ -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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
miniprogram/images/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
miniprogram/images/user.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1 @@
Page({})

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
<!-- placeholder -->

View File

@@ -0,0 +1 @@
/* placeholder */

View File

@@ -0,0 +1 @@
Page({})

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
<!-- placeholder -->

View File

@@ -0,0 +1 @@
/* placeholder */

View File

@@ -0,0 +1 @@
Page({})

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
<!-- placeholder -->

View File

@@ -0,0 +1 @@
/* placeholder */

View File

@@ -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()
}
}
})

View File

@@ -0,0 +1,7 @@
{
"navigationStyle": "custom",
"usingComponents": {
"custom-tab-bar": "/custom-tab-bar/index"
},
"enablePullDownRefresh": false
}

View File

@@ -0,0 +1,59 @@
<!--pages/enterprise/index.wxml - 企业版首页-->
<view class="container" style="padding-top: {{navbarHeight}}px;">
<!-- 自定义导航栏 -->
<view class="custom-navbar" style="padding-top: {{statusBarHeight}}rpx;">
<view class="navbar-content">
<view class="switch-personal-btn" bindtap="switchToPersonal">
<text class="personal-icon">👤</text>
<text class="personal-text">个人版</text>
</view>
<view class="navbar-title">{{siteTitle || '神仙团队AI性格测试'}}</view>
<view class="navbar-placeholder"></view>
</view>
</view>
<!-- 背景装饰 -->
<view class="bg-decoration bg-top-right"></view>
<view class="bg-decoration bg-bottom-left"></view>
<view class="top-image-section">
<view class="image-container">
<view class="image-wrapper">
<image class="main-image" src="/images/mbti-team-image.png" mode="aspectFit"></image>
</view>
<view class="float-tag tag-1">面相分析</view>
<view class="float-tag tag-2">骨相分析</view>
<view class="float-tag tag-3">性格测评</view>
</view>
</view>
<view class="process-section">
<view class="section-title">测试流程</view>
<view class="process-steps">
<view class="step-item">
<view class="step-circle active">1</view>
<text class="step-label">STEP1</text>
<text class="step-text">拍摄照片</text>
</view>
<view class="step-line"></view>
<view class="step-item">
<view class="step-circle">2</view>
<text class="step-label">STEP2</text>
<text class="step-text">{{aiAnalysisText || '智能分析'}}</text>
</view>
<view class="step-line"></view>
<view class="step-item">
<view class="step-circle">3</view>
<text class="step-label">STEP3</text>
<text class="step-text">生成报告</text>
</view>
</view>
</view>
<view class="start-button" bindtap="startAITest">
<text class="button-text">{{startButtonEnterprise || '开始面部测试'}}</text>
</view>
<!-- 自定义底部导航 -->
<custom-tab-bar />
</view>

View File

@@ -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;
}

View File

@@ -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' }) })
}
})

View File

@@ -0,0 +1,6 @@
{
"navigationBarTitleText": "我的简历",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black",
"usingComponents": {}
}

View File

@@ -0,0 +1,66 @@
<!--pages/enterprise/resume-history.wxml-->
<view class="page">
<!-- 背景装饰 -->
<view class="bg-decoration bg-top-right"></view>
<view class="bg-decoration bg-bottom-left"></view>
<view class="container">
<!-- 提示语 + 上传入口 -->
<view class="tip-section" wx:if="{{!loading}}">
<text class="tip-text" wx:if="{{list.length > 0}}">仅展示当前绑定企业的简历上传记录</text>
</view>
<view class="content" wx:if="{{!loading && list.length > 0}}">
<view class="resume-card" wx:for="{{list}}" wx:key="id">
<view class="card-body">
<view class="file-info">
<view class="file-icon-wrap">
<text class="file-icon">📄</text>
</view>
<view class="file-meta">
<text class="name">{{item.fileName}}</text>
<text class="time">{{item.uploadedAtStr}}</text>
</view>
<view class="top-right-actions">
<view class="default-badge" wx:if="{{item.isDefault}}">默认</view>
<view class="set-default-text" wx:if="{{!item.isDefault}}" bindtap="setDefault" data-id="{{item.id}}">设为默认</view>
</view>
</view>
<view class="card-footer">
<view class="secondary-actions">
<view class="action-btn text-btn delete-btn" bindtap="deleteResume" data-id="{{item.id}}">删除</view>
</view>
<view class="primary-actions">
<view class="action-btn primary-btn" bindtap="previewResume" data-url="{{item.url}}" data-file-name="{{item.fileName}}">
预览查看
</view>
</view>
</view>
</view>
</view>
</view>
<view class="empty-state" wx:if="{{!loading && empty}}">
<view class="empty-icon-wrap">
<text class="empty-icon">📁</text>
</view>
<text class="empty-text">暂无上传记录</text>
<text class="empty-tip">点击下方按钮上传简历,或返回企业版首页上传</text>
<view class="go-upload-btn" bindtap="uploadResume">上传简历</view>
<view class="back-home-btn" bindtap="goBack">返回首页</view>
</view>
<view class="loading-wrap" wx:if="{{loading}}">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
</view>
<!-- 底部悬浮上传:有数据时显示 -->
<view class="float-upload-wrap" wx:if="{{!loading && list.length > 0}}" bindtap="uploadResume">
<view class="float-upload-btn">
<text class="float-upload-text">上传简历</text>
</view>
</view>
</view>

View File

@@ -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;
}

View File

@@ -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' })
},
})

View File

@@ -0,0 +1,6 @@
{
"navigationBarTitleText": "测试记录",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black",
"usingComponents": {}
}

View File

@@ -0,0 +1,74 @@
<!--pages/history/index.wxml - 测试历史记录(按旧版模板重构)-->
<view class="container">
<!-- 测试类型标签 -->
<view class="tabs-container">
<scroll-view scroll-x class="tabs-scroll">
<view class="tabs">
<view class="tab-item {{activeTab === 'all' ? 'active' : ''}}" bindtap="changeTab" data-tab="all">
<text class="tab-text">全部</text>
</view>
<view class="tab-item {{activeTab === 'mbti' ? 'active' : ''}}" bindtap="changeTab" data-tab="mbti">
<text class="tab-text">MBTI</text>
</view>
<view class="tab-item {{activeTab === 'pdp' ? 'active' : ''}}" bindtap="changeTab" data-tab="pdp">
<text class="tab-text">PDP</text>
</view>
<view class="tab-item {{activeTab === 'disc' ? 'active' : ''}}" bindtap="changeTab" data-tab="disc">
<text class="tab-text">DISC</text>
</view>
<view class="tab-item {{activeTab === 'ai' ? 'active' : ''}}" bindtap="changeTab" data-tab="ai">
<text class="tab-text">面相</text>
</view>
<view wx:if="{{isEnterprise}}" class="tab-item {{activeTab === 'resume' ? 'active' : ''}}" bindtap="changeTab" data-tab="resume">
<text class="tab-text">简历</text>
</view>
</view>
</scroll-view>
</view>
<!-- 空状态 -->
<view class="empty-container" wx:if="{{list.length === 0 && !loading}}">
<text class="empty-icon">📋</text>
<text class="empty-text">暂无{{activeTab === 'all' ? '' : tabName}}历史记录</text>
<view class="primary-button" bindtap="goToTest">
<text class="button-text">开始测试</text>
</view>
</view>
<!-- 测试记录列表 -->
<view class="test-list" wx:if="{{list.length > 0}}">
<view class="test-card" wx:for="{{list}}" wx:key="key" bindtap="viewDetail" data-type="{{item.type}}" data-index="{{index}}" data-id="{{item.id}}">
<view class="card-content">
<view class="icon-circle icon-{{item.type}}">
<text class="icon-emoji">{{item.emoji}}</text>
</view>
<view class="test-details">
<view class="test-header-row">
<text class="test-title">{{item.typeName}}</text>
<view class="result-badge badge-{{item.type}}">
<text class="badge-text">{{item.resultText}}</text>
</view>
</view>
<view class="test-time-row">
<text class="test-time">{{item.testTime}}</text>
<text class="test-enterprise" wx:if="{{item.enterpriseName}}">{{item.enterpriseName}}</text>
</view>
</view>
</view>
</view>
<!-- 加载中 -->
<view class="load-more-row" wx:if="{{loading}}">
<text class="load-more-text">加载中...</text>
</view>
<view wx:elif="{{list.length > 0}}" class="load-more-row">
<text class="load-more-text no-more">共 {{total}} 条</text>
</view>
</view>
<!-- 首次加载骨架 -->
<view class="empty-container" wx:if="{{loading && list.length === 0}}">
<text class="load-more-text">加载中...</text>
</view>
</view>

View File

@@ -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;
}

View File

@@ -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()
}
}
})
}
})

View File

@@ -0,0 +1,6 @@
{
"navigationBarTitleText": "拍照",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black",
"usingComponents": {}
}

View File

@@ -0,0 +1,69 @@
<!--pages/index/camera.wxml - AI拍照页面按旧版模板重构-->
<view class="container">
<view class="progress-section">
<view class="progress-info">
<text class="step-text">步骤 {{photoIndex + 1}}/3</text>
<text class="photo-count">{{photos.length}}/3 张照片已完成</text>
</view>
<view class="progress-bars">
<view class="progress-bar {{photos.length >= 1 ? 'completed' : 'pending'}}"></view>
<view class="progress-bar {{photos.length >= 2 ? 'completed' : 'pending'}}"></view>
<view class="progress-bar {{photos.length >= 3 ? 'completed' : 'pending'}}"></view>
</view>
<view class="instruction-card">
<view class="instruction-content">
<view class="step-number">{{photoIndex + 1}}</view>
<view class="instruction-text">
<text class="angle-text">{{guideText}}</text>
<text class="tip-text">请保持自然表情,确保光线充足</text>
</view>
</view>
</view>
</view>
<view class="camera-container">
<view wx:if="{{photos.length < 3}}" class="camera-preview">
<camera
class="camera"
device-position="front"
flash="off"
binderror="onCameraError"
></camera>
</view>
<view wx:else class="photos-preview">
<view class="photo-item" wx:for="{{photos}}" wx:key="index">
<image class="photo-image" src="{{item}}" mode="aspectFill"></image>
<view class="photo-label">{{index === 0 ? '正面' : (index === 1 ? '左侧' : '右侧')}}</view>
</view>
</view>
</view>
<view class="button-container">
<view wx:if="{{photos.length < 3}}" class="capture-actions">
<view class="capture-button" bindtap="takePhoto">
<text class="button-text">拍摄{{guideText}}照片</text>
</view>
<view class="album-button" bindtap="goToUpload">
<text class="album-button-text">从相册选择</text>
</view>
</view>
<view wx:else class="action-buttons">
<view class="action-button secondary" bindtap="retakeAll">
<text class="action-button-text">重新拍摄</text>
</view>
<view class="action-button primary" bindtap="completeCapture">
<text class="action-button-text">立即{{aiAnalysisText || '智能分析'}}</text>
</view>
</view>
</view>
<!-- 手机号授权入口:仅在未授权时显示,就地弹出微信系统手机号授权 -->
<view class="phone-auth-section" wx:if="{{needPhoneAuth}}">
<text class="phone-auth-tip">为保障服务与联系,请先授权手机号。</text>
<button class="phone-auth-btn" open-type="getPhoneNumber" bindgetphonenumber="onGetPhoneNumber">
授权手机号后开始查看报告
</button>
</view>
</view>

View File

@@ -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;
}

View File

@@ -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()
}
}
})

View File

@@ -0,0 +1,6 @@
{
"navigationStyle": "custom",
"usingComponents": {
"custom-tab-bar": "/custom-tab-bar/index"
}
}

View File

@@ -0,0 +1,61 @@
<!--pages/index/index.wxml - AI测试首页按旧版模板重构-->
<view class="container" style="padding-top: {{navbarHeight}}px;">
<!-- 自定义导航栏 -->
<view class="custom-navbar" style="padding-top: {{statusBarHeight}}rpx;">
<view class="navbar-content">
<view class="switch-enterprise-btn" wx:if="{{showEnterpriseEntry}}" bindtap="switchToEnterprise">
<text class="enterprise-icon">🏢</text>
<text class="enterprise-text">企业版</text>
</view>
<view class="navbar-title">{{siteTitle || '神仙团队AI性格测试'}}</view>
<view class="navbar-placeholder"></view>
</view>
</view>
<!-- 背景装饰(与企业版一致) -->
<view class="bg-decoration bg-top-right"></view>
<view class="bg-decoration bg-bottom-left"></view>
<view class="top-image-section">
<view class="image-container">
<view class="image-wrapper">
<image class="main-image" src="/images/mbti-team-image.png" mode="aspectFit"></image>
</view>
<view class="float-tag tag-1">面相分析</view>
<view class="float-tag tag-2">骨相分析</view>
<view class="float-tag tag-3">性格测评</view>
</view>
</view>
<view class="process-section">
<view class="section-title">测试流程</view>
<view class="process-steps">
<view class="step-item">
<view class="step-circle active">1</view>
<text class="step-label">STEP1</text>
<text class="step-text">拍摄照片</text>
</view>
<view class="step-line"></view>
<view class="step-item">
<view class="step-circle">2</view>
<text class="step-label">STEP2</text>
<text class="step-text">{{aiAnalysisText || '智能分析'}}</text>
</view>
<view class="step-line"></view>
<view class="step-item">
<view class="step-circle">3</view>
<text class="step-label">STEP3</text>
<text class="step-text">生成报告</text>
</view>
</view>
</view>
<view class="start-button" bindtap="startCamera">
<text class="button-text">{{startButtonText || '开始面相测试'}}</text>
</view>
<view class="ai-generated-tip">人工智能生成</view>
<!-- 自定义底部导航 -->
<custom-tab-bar />
</view>

View File

@@ -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;
}

View File

@@ -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分析对应的测试记录IDmbti_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分析APIaiPhotos 应为上传后的 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)
// 调用后端APIappScope='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()
}
}
})

View File

@@ -0,0 +1,6 @@
{
"navigationBarTitleText": "分析报告",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black",
"usingComponents": {}
}

View File

@@ -0,0 +1,304 @@
<!--pages/index/result.wxml - AI分析结果按旧版模板重构-->
<view class="container">
<!-- 分析中状态 -->
<view class="analyzing-modal" wx:if="{{isAnalyzing}}">
<view class="analyzing-content">
<view class="analyzing-icon">
<view class="spinner"></view>
</view>
<text class="analyzing-title">{{analyzingTitle || '正在分析中'}}</text>
<text class="analyzing-desc">{{analyzingTip}}</text>
<view class="analyzing-bar">
<view class="analyzing-bar-fill" style="width: {{progress}}%"></view>
</view>
<text class="analyzing-hint">这个过程可能需要30秒到1分钟请耐心等待...</text>
</view>
</view>
<!-- 无人脸错误状态 -->
<view class="no-face-wrap" wx:if="{{!isAnalyzing && noFaceError}}">
<view class="no-face-card">
<view class="no-face-icon">
<text class="no-face-emoji">🚫</text>
</view>
<text class="no-face-title">未检测到人脸</text>
<text class="no-face-desc">{{noFaceMessage}}</text>
<view class="no-face-tips">
<text class="no-face-tips-title">拍摄建议:</text>
<text class="no-face-tip-item">• 确保面部完整出现在画面中</text>
<text class="no-face-tip-item">• 保持光线充足,避免逆光</text>
<text class="no-face-tip-item">• 正对镜头,不要遮挡面部</text>
<text class="no-face-tip-item">• 保持适当距离,不要过近或过远</text>
</view>
<view class="no-face-btn" bindtap="retake">
<text class="no-face-btn-text">重新拍摄</text>
</view>
</view>
</view>
<!-- 分析结果 -->
<view wx:if="{{!isAnalyzing && showResult}}">
<!-- 状态横幅 -->
<view class="status-banner {{hasError ? 'error-banner' : ''}}">
<view class="status-dot {{hasError ? 'error-dot' : ''}}"></view>
<text class="status-text">{{hasError ? '分析出现异常' : '分析完成,以下是您的个性化报告'}}</text>
</view>
<!-- 错误内容 -->
<view class="error-content" wx:if="{{hasError}}">
<text class="error-title">错误信息</text>
<text class="error-message">{{errorMessage}}</text>
<view class="retry-btn" bindtap="retake">
<text class="retry-text">重新分析</text>
</view>
</view>
<!-- 性格类型分析卡片 -->
<view class="card personality-card" wx:if="{{!hasError}}">
<text class="card-title">性格类型分析</text>
<!-- MBTI类型 -->
<view class="personality-type">
<text class="type-code">{{result.mbti}}</text>
<text class="type-name">{{result.title}}</text>
<text class="type-desc">{{result.summary}}</text>
</view>
<!-- PDP和DISC -->
<view class="type-details">
<view class="type-box pdp-box">
<text class="type-label">PDP主性格</text>
<text class="type-value">{{result.pdp || '--'}}</text>
<text class="type-sub" wx:if="{{result.pdpAux}}">辅助: {{result.pdpAux}}</text>
</view>
<view class="type-box disc-box">
<text class="type-label">DISC类型</text>
<text class="type-value">{{result.disc || '--'}}型</text>
<text class="type-sub" wx:if="{{result.discAux}}">辅助: {{result.discAux}}</text>
</view>
</view>
<!-- 主要优势 -->
<view class="advantages-box" wx:if="{{result.traits.length > 0}}">
<text class="advantages-label">主要优势</text>
<view class="advantages-list">
<view class="advantage-item" wx:for="{{result.traits}}" wx:key="*this">
<view class="advantage-dot"></view>
<text class="advantage-text">{{item}}</text>
</view>
</view>
</view>
<view class="next-btn" bindtap="goToMBTI">
<text class="next-btn-text">下一步:详细性格测试 →</text>
</view>
</view>
<!-- 性格概述 -->
<view class="card" wx:if="{{!hasError && result.summary}}">
<text class="card-title">性格概述</text>
<text class="card-text">{{result.summary}}</text>
</view>
<!-- 盖洛普前三大优势(付费后/免费皆展示,有数据才显示) -->
<view class="card gallup-card" wx:if="{{!hasError && result.gallupTop3.length > 0}}">
<text class="card-title">盖洛普前三大优势</text>
<view class="gallup-list">
<view class="gallup-item" wx:for="{{result.gallupTop3}}" wx:key="*this">
<text class="gallup-rank">{{index + 1}}</text>
<text class="gallup-text">{{item}}</text>
</view>
</view>
</view>
<!-- 付费解锁完整报告(不展示虚拟假数据) -->
<view class="card paywall-card" wx:if="{{!hasError && payInfo.requiresPayment && !payInfo.isPaid}}">
<view class="paywall-content">
<text class="paywall-fake-title">完整版性格深度解析</text>
<text class="paywall-tip">解锁后将展示完整报告内容</text>
<!-- 中间解锁按钮:未有手机号时就地触发微信手机号授权 -->
<button
class="paywall-btn"
wx:if="{{!hasPhone}}"
open-type="getPhoneNumber"
bindgetphonenumber="onGetPhoneNumberForFacePay"
>
<text class="paywall-btn-main">解锁完整报告</text>
<text class="paywall-btn-price">¥{{payInfo.amountYuan || 0}} / 次</text>
</button>
<!-- 已有手机号时:普通按钮,直接解锁 -->
<button
class="paywall-btn"
wx:elif="{{hasPhone}}"
bindtap="unlockFullReport"
>
<text class="paywall-btn-main">解锁完整报告</text>
<text class="paywall-btn-price">¥{{payInfo.amountYuan || 0}} / 次</text>
</button>
<text class="paywall-tip">一次性解锁本次{{reportTitle || '分析报告'}},永久保存在「历史记录」中</text>
</view>
</view>
<!-- ========== 详细分析区(解锁后可见) ========== -->
<block wx:if="{{!hasError && (!payInfo.requiresPayment || payInfo.isPaid)}}">
<!-- 面相分析 -->
<block wx:if="{{result.faceAnalysis}}">
<!-- 完整结构化面相(旧 object 格式) -->
<view class="card" wx:if="{{result.faceAnalysis.facialFeatures && result.faceAnalysis.facialFeatures.length > 0}}">
<text class="card-title">面部特征分析</text>
<view class="feature-list">
<view class="feature-item" wx:for="{{result.faceAnalysis.facialFeatures}}" wx:key="label">
<text class="feature-label">{{item.label}}</text>
<text class="feature-desc">{{item.description}}</text>
</view>
</view>
</view>
</block>
<!-- 字符串版面相(新 prompt 返回) -->
<view class="card" wx:elif="{{result.faceAnalysisText}}">
<text class="card-title">面相分析</text>
<text class="card-text">{{result.faceAnalysisText}}</text>
</view>
<!-- 骨相分析 -->
<block wx:if="{{result.boneAnalysis && result.boneAnalysis.boneFormSummary}}">
<!-- 完整结构化骨相(旧 object 格式) -->
<view class="card">
<text class="card-title">骨相分析(《冰鉴》八骨)</text>
<view class="bone-content">
<view class="bone-item" wx:if="{{result.boneAnalysis.boneFormSummary.elementType}}">
<text class="bone-label">五行形相:</text>
<text class="bone-text">{{result.boneAnalysis.boneFormSummary.elementType}}</text>
</view>
<view class="bone-item" wx:if="{{result.boneAnalysis.boneFormSummary.boneFleshRelation}}">
<text class="bone-label">骨肉关系:</text>
<text class="bone-text">{{result.boneAnalysis.boneFormSummary.boneFleshRelation}}</text>
</view>
</view>
</view>
</block>
<!-- 字符串版骨相(新 prompt 返回) -->
<view class="card" wx:elif="{{result.boneAnalysisText}}">
<text class="card-title">骨相分析(《冰鉴》八骨)</text>
<text class="card-text">{{result.boneAnalysisText}}</text>
</view>
<!-- 人际关系 -->
<view class="card" wx:if="{{result.relationship}}">
<text class="card-title">人际关系与团队合作</text>
<text class="card-text">{{result.relationship}}</text>
</view>
<!-- 企业版:简历亮点 -->
<view class="card" wx:if="{{result.resumeHighlights}}">
<text class="card-title">简历亮点</text>
<text class="card-text">{{result.resumeHighlights}}</text>
</view>
<!-- 企业版:职业画像 portrait -->
<view class="card" wx:if="{{result.portrait}}">
<text class="card-title">职业画像</text>
<view class="enterprise-section" wx:if="{{result.portrait.coreStrengths && result.portrait.coreStrengths.length > 0}}">
<text class="enterprise-label">核心优势</text>
<view class="enterprise-tags">
<text class="enterprise-tag" wx:for="{{result.portrait.coreStrengths}}" wx:key="*this">{{item}}</text>
</view>
</view>
<view class="enterprise-section" wx:if="{{result.portrait.coreRisks && result.portrait.coreRisks.length > 0}}">
<text class="enterprise-label">潜在风险</text>
<view class="enterprise-tags">
<text class="enterprise-tag risk" wx:for="{{result.portrait.coreRisks}}" wx:key="*this">{{item}}</text>
</view>
</view>
<view class="enterprise-section" wx:if="{{result.portrait.workStyle}}">
<text class="enterprise-label">工作风格</text>
<text class="card-text">{{result.portrait.workStyle}}</text>
</view>
</view>
<!-- 企业版HR视角 hrView -->
<view class="card" wx:if="{{result.hrView}}">
<text class="card-title">HR视角</text>
<view class="enterprise-section" wx:if="{{result.hrView.roleRecommend}}">
<text class="enterprise-label">岗位推荐</text>
<view wx:if="{{result.hrView.roleRecommend.bestFit && result.hrView.roleRecommend.bestFit.length > 0}}">
<text class="enterprise-sublabel">最适合</text>
<view class="enterprise-tags">
<text class="enterprise-tag" wx:for="{{result.hrView.roleRecommend.bestFit}}" wx:key="*this">{{item}}</text>
</view>
</view>
<view wx:if="{{result.hrView.roleRecommend.notSuitable && result.hrView.roleRecommend.notSuitable.length > 0}}" style="margin-top: 12rpx;">
<text class="enterprise-sublabel">不适合</text>
<view class="enterprise-tags">
<text class="enterprise-tag risk" wx:for="{{result.hrView.roleRecommend.notSuitable}}" wx:key="*this">{{item}}</text>
</view>
</view>
</view>
<view class="enterprise-section" wx:if="{{result.hrView.lifecycle}}">
<text class="enterprise-label">成长周期</text>
<view class="lifecycle-list">
<view class="lifecycle-item" wx:if="{{result.hrView.lifecycle.onboarding}}">
<text class="lifecycle-key">入职适应</text>
<text class="lifecycle-val">{{result.hrView.lifecycle.onboarding}}</text>
</view>
<view class="lifecycle-item" wx:if="{{result.hrView.lifecycle.probation}}">
<text class="lifecycle-key">试用期</text>
<text class="lifecycle-val">{{result.hrView.lifecycle.probation}}</text>
</view>
<view class="lifecycle-item" wx:if="{{result.hrView.lifecycle.growth}}">
<text class="lifecycle-key">成长期</text>
<text class="lifecycle-val">{{result.hrView.lifecycle.growth}}</text>
</view>
<view class="lifecycle-item" wx:if="{{result.hrView.lifecycle.retention}}">
<text class="lifecycle-key">留人因素</text>
<text class="lifecycle-val">{{result.hrView.lifecycle.retention}}</text>
</view>
</view>
</view>
<view class="enterprise-section" wx:if="{{result.hrView.performance}}">
<text class="enterprise-label">绩效潜力</text>
<text class="card-text" wx:if="{{result.hrView.performance.potential}}">潜力:{{result.hrView.performance.potential}}</text>
<view wx:if="{{result.hrView.performance.drivers && result.hrView.performance.drivers.length > 0}}" style="margin-top: 8rpx;">
<text class="enterprise-sublabel">驱动因子:</text>
<text class="card-text">{{result.hrView.performance.drivers.join('、')}}</text>
</view>
</view>
<view class="enterprise-section" wx:if="{{result.hrView.teamFit}}">
<text class="enterprise-label">团队适配</text>
<text class="card-text">{{result.hrView.teamFit.bestTeam || ''}}</text>
<text class="card-text" wx:if="{{result.hrView.teamFit.manageAdvice}}" style="margin-top: 8rpx; display: block;">管理建议:{{result.hrView.teamFit.manageAdvice}}</text>
</view>
</view>
<!-- 企业版:老板视角 bossView -->
<view class="card boss-card" wx:if="{{result.bossView}}">
<text class="card-title">老板视角</text>
<text class="boss-headline" wx:if="{{result.bossView.headline}}">{{result.bossView.headline}}</text>
<view class="boss-metrics" wx:if="{{result.bossView.metrics && result.bossView.metrics.length > 0}}">
<view class="boss-metric" wx:for="{{result.bossView.metrics}}" wx:key="label">
<text class="boss-metric-label">{{item.label}}</text>
<text class="boss-metric-value {{item.level}}">{{item.value}}</text>
</view>
</view>
<text class="card-text" wx:if="{{result.bossView.costInsight}}" style="margin-top: 16rpx;">用人成本预判:{{result.bossView.costInsight}}</text>
</view>
</block>
<!-- 操作按钮 -->
<view class="action-section" wx:if="{{!hasError}}">
<button class="btn btn-primary" open-type="share">
<text class="btn-text">分享我的分析报告</text>
</button>
<view class="btn btn-outline" bindtap="retake">
<text class="btn-text-outline">重新分析</text>
</view>
<view class="btn btn-outline" bindtap="goHome">
<text class="btn-text-outline">返回首页</text>
</view>
</view>
<view class="ai-generated-tip">人工智能生成</view>
</view>
</view>

View File

@@ -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;
}

View File

@@ -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()
}
}
})
}
})

View File

@@ -0,0 +1,8 @@
{
"navigationBarTitleText": "拍照",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black",
"usingComponents": {
"custom-tab-bar": "/custom-tab-bar/index"
}
}

View File

@@ -0,0 +1,113 @@
<!--pages/index/camera.wxml - AI拍照页面仿上传照片指南布局-->
<view class="page">
<!-- 主体内容 -->
<view class="content">
<!-- 引导文案 -->
<view class="intro">
<text class="intro-title">多角度拍摄更精准</text>
<view class="intro-desc-wrap">
<text class="intro-desc">为了更准确地通过骨相与面相分析您的性格,</text>
<text class="intro-desc">请上传以下三个维度的照片。</text>
</view>
</view>
<!-- 三个角度上传卡片 -->
<view class="steps">
<!-- 正面 -->
<view class="step-item">
<view class="step-index step-index-primary">
<text class="step-index-text step-index-text-primary">1</text>
</view>
<view class="step-body">
<text class="step-title">正面</text>
<text class="step-subtitle">目视前方,五官端正,不遮挡额头</text>
<view class="step-upload-row">
<image class="sample-image" mode="aspectFill" src="{{sampleImages[0]}}"></image>
<view class="upload-card" bindtap="onUploadFront">
<image wx:if="{{photos[0]}}" class="upload-preview" mode="aspectFill" src="{{photos[0]}}"></image>
<block wx:else>
<text class="upload-icon">📷</text>
<text class="upload-text">点击上传</text>
</block>
</view>
</view>
</view>
</view>
<!-- 左侧 45 度 -->
<view class="step-item">
<view class="step-index step-index-secondary">
<text class="step-index-text step-index-text-secondary">2</text>
</view>
<view class="step-body">
<text class="step-title">左侧 45度</text>
<text class="step-subtitle">展示左侧面颊,用于分析面部轮廓</text>
<view class="step-upload-row">
<image class="sample-image" mode="aspectFill" src="{{sampleImages[1]}}"></image>
<view class="upload-card" bindtap="onUploadLeft">
<image wx:if="{{photos[1]}}" class="upload-preview" mode="aspectFill" src="{{photos[1]}}"></image>
<block wx:else>
<text class="upload-icon">📷</text>
<text class="upload-text">点击上传</text>
</block>
</view>
</view>
</view>
</view>
<!-- 右侧 45 度 -->
<view class="step-item">
<view class="step-index step-index-secondary">
<text class="step-index-text step-index-text-secondary">3</text>
</view>
<view class="step-body">
<text class="step-title">右侧 45度</text>
<text class="step-subtitle">展示右侧面颊,完整捕捉面部信息</text>
<view class="step-upload-row">
<image class="sample-image" mode="aspectFill" src="{{sampleImages[2]}}"></image>
<view class="upload-card" bindtap="onUploadRight">
<image wx:if="{{photos[2]}}" class="upload-preview" mode="aspectFill" src="{{photos[2]}}"></image>
<block wx:else>
<text class="upload-icon">📷</text>
<text class="upload-text">点击上传</text>
</block>
</view>
</view>
</view>
</view>
</view>
<!-- 温馨提示 -->
<view class="tips-card">
<view class="tips-title-row">
<view class="tips-icon"></view>
<text class="tips-title">温馨提示</text>
</view>
<view class="tips-list">
<text class="tips-item">· 光线充足,背景整洁,避免过曝或过暗</text>
<text class="tips-item">· 保持表情自然,无需刻意微笑</text>
<text class="tips-item">· 如有佩戴眼镜,请确保镜片不反光</text>
</view>
</view>
<!-- 手机号授权入口:仅在未授权时显示 -->
<view class="phone-auth-section" wx:if="{{needPhoneAuth}}">
<text class="phone-auth-tip">为保障服务与联系,请先授权手机号。</text>
<button
class="phone-auth-btn"
open-type="getPhoneNumber"
bindgetphonenumber="onGetPhoneNumber"
>
授权手机号后开始查看报告
</button>
</view>
</view>
<!-- 底部操作按钮 -->
<view class="footer">
<button class="primary-btn" bindtap="completeCapture">智能分析</button>
</view>
<!-- 自定义底部导航,与首页保持一致 -->
<custom-tab-bar />
</view>

View File

@@ -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;
}

View File

@@ -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' })
},
})
},
})

View File

@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "授权手机号",
"usingComponents": {}
}

View File

@@ -0,0 +1,5 @@
<view class="container">
<view class="tip">为保障服务与联系需要,请授权您的手机号。</view>
<view class="tip sub">授权后可在「查看报告」「支付」「企业咨询」等场景使用,仅需授权一次。</view>
<button class="auth-btn" open-type="getPhoneNumber" bindgetphonenumber="onGetPhoneNumber">授权手机号</button>
</view>

View File

@@ -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;
}

View File

@@ -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()
}
}
})

View File

@@ -0,0 +1,6 @@
{
"navigationBarTitleText": "",
"navigationBarBackgroundColor": "#F9FAFB",
"navigationBarTextStyle": "black",
"usingComponents": {}
}

View File

@@ -0,0 +1,212 @@
<!--pages/profile/index.wxml-->
<view class="page">
<!-- 顶部栏 -->
<view class="topbar">
<text class="topbar-title">我的</text>
<view class="topbar-actions">
<!-- <view class="settings-btn" bindtap="goToSettings">
<text class="settings-icon">⚙️</text>
</view> -->
</view>
</view>
<!-- 未登录 -->
<view class="user-card" wx:if="{{!hasLogin}}" bindtap="doLogin">
<view class="avatar-ring">
<view class="avatar-letter-wrap" style="background:{{avatarBgColor}}">
<text class="avatar-letter">{{avatarLetter}}</text>
</view>
</view>
<view class="user-meta">
<text class="user-name" wx:if="{{loginLoading}}">登录中...</text>
<text class="user-name" wx:elif="{{loginFailed}}">登录失败,点击重试</text>
<text class="user-name" wx:else>点击登录</text>
<text class="user-sub">登录后查看你的测试结果</text>
</view>
<text class="chevron"></text>
</view>
<!-- 已登录:用户卡片(点击进入个人资料) -->
<view class="user-card" wx:if="{{hasLogin}}" bindtap="goToUserProfile">
<!-- 头像区 -->
<view class="avatar-wrap">
<view class="avatar-ring">
<image wx:if="{{userInfo && (userInfo.avatarUrl || userInfo.avatar)}}"
class="avatar-img"
src="{{userInfo.avatarUrl || userInfo.avatar}}"
mode="aspectFill"/>
<view wx:else class="avatar-letter-wrap" style="background:{{avatarBgColor}}">
<text class="avatar-letter">{{avatarLetter}}</text>
</view>
</view>
<view class="online-dot"></view>
</view>
<!-- 昵称 + 标签 -->
<view class="user-meta">
<text class="nickname-text">{{nicknameDisplay || '点击设置昵称'}}</text>
<!-- 类型标签 -->
<scroll-view scroll-x class="tags-scroll">
<view class="tags-row">
<view class="tag tag-purple" wx:if="{{mbtiType}}">
<text class="tag-text">{{mbtiType}}</text>
</view>
<view class="tag tag-blue" wx:if="{{discType}}">
<text class="tag-text">{{discType}}型</text>
</view>
<view class="tag tag-orange" wx:if="{{pdpType}}">
<text class="tag-text">{{pdpType}}</text>
</view>
<view class="tag tag-gray" wx:if="{{!mbtiType && !discType && !pdpType}}">
<text class="tag-text">暂无测试记录</text>
</view>
</view>
</scroll-view>
</view>
<text class="chevron"></text>
</view>
<!-- 最新测试卡片(横向滚动) -->
<view class="section" wx:if="{{hasLogin && hasResults}}">
<view class="section-header">
<text class="section-title">最新测试</text>
<view class="section-link" bindtap="goToHistory">
<text class="section-link-text">查看全部</text>
</view>
</view>
<scroll-view scroll-x class="cards-scroll" enhanced show-scrollbar="{{false}}">
<view class="cards-row">
<!-- MBTI -->
<view class="result-card card-purple" wx:if="{{mbtiType}}" bindtap="viewMBTI">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-purple">
<text class="card-icon">🧠</text>
</view>
<text class="card-label">MBTI性格</text>
<text class="card-value">{{mbtiType}}</text>
<text class="card-time">{{mbtiTime}}</text>
</view>
<!-- DISC -->
<view class="result-card card-blue" wx:if="{{discType}}" bindtap="viewDISC">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-blue">
<text class="card-icon">📊</text>
</view>
<text class="card-label">DISC测评</text>
<text class="card-value">{{discType}}型</text>
<text class="card-time">{{discTime}}</text>
</view>
<!-- PDP -->
<view class="result-card card-orange" wx:if="{{pdpType}}" bindtap="viewPDP">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-orange">
<text class="card-icon">🦁</text>
</view>
<text class="card-label">PDP行为</text>
<text class="card-value">{{pdpType}}</text>
<text class="card-time">{{pdpTime}}</text>
</view>
<!-- AI 面相 -->
<view class="result-card card-rose" wx:if="{{aiType}}" bindtap="viewAI">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-rose">
<text class="card-icon">👁️</text>
</view>
<text class="card-label">面相分析</text>
<text class="card-value">{{aiType}}</text>
<text class="card-time">{{aiTime}}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 推广中心(根据管理端开关显示/隐藏,标题可配置) -->
<view class="section px-section" wx:if="{{hasLogin && promoDistributionEnabled}}">
<view class="promo-card" bindtap="goToPromo">
<view class="promo-header">
<view class="promo-title-wrap">
<text class="promo-icon">📈</text>
<text class="promo-title">{{promoCenterTitle}}</text>
</view>
</view>
<view class="promo-stats">
<view class="promo-stat-item">
<text class="promo-stat-label">邀请好友</text>
<text class="promo-stat-value">{{promoTotalInvite}}</text>
</view>
<view class="promo-stat-divider"></view>
<view class="promo-stat-item">
<text class="promo-stat-label">累计收益</text>
<text class="promo-stat-value">¥{{promoTotalEarned}}</text>
</view>
<view class="promo-stat-divider"></view>
<view class="promo-stat-item">
<text class="promo-stat-label">可提现</text>
<text class="promo-stat-value promo-highlight">¥{{promoWithdrawable}}</text>
</view>
</view>
</view>
</view>
<!-- 服务菜单 -->
<view class="section px-section">
<text class="section-title">深度服务</text>
<view class="menu-card">
<!-- 专业报告:跳到个人版开通页面 -->
<view class="menu-item" bindtap="goToPurchasePersonal">
<view class="menu-icon-wrap menu-icon-red">
<text class="menu-icon">📄</text>
</view>
<view class="menu-content">
<text class="menu-title">专业报告</text>
<text class="menu-sub">解锁完整的深度性格解析</text>
</view>
<text class="menu-chevron"></text>
</view>
<view class="menu-divider"></view>
<!-- 企业版服务:跳到企业版开通页面 -->
<view class="menu-item" bindtap="goToPurchaseEnterprise">
<view class="menu-icon-wrap menu-icon-indigo">
<text class="menu-icon">🏢</text>
</view>
<view class="menu-content">
<text class="menu-title">企业版服务</text>
<text class="menu-sub">团队测评与人才管理方案</text>
</view>
<text class="menu-chevron"></text>
</view>
<view class="menu-divider"></view>
<!-- 测试历史:仍然进入历史记录页面 -->
<view class="menu-item" bindtap="goToHistory">
<view class="menu-icon-wrap menu-icon-amber">
<text class="menu-icon">🕒</text>
</view>
<view class="menu-content">
<text class="menu-title">测试历史</text>
<text class="menu-sub">{{testCount > 0 ? testCount + '条记录' : '查看过往所有测试记录'}}</text>
</view>
<text class="menu-chevron"></text>
</view>
<view class="menu-divider" wx:if="{{hasEnterprise}}"></view>
<!-- 我的简历:仅绑定企业的用户可见,展示当前企业下的简历并可设默认 -->
<view class="menu-item" wx:if="{{hasEnterprise}}" bindtap="goToMyResume">
<view class="menu-icon-wrap menu-icon-indigo">
<text class="menu-icon">📋</text>
</view>
<view class="menu-content">
<text class="menu-title">我的简历</text>
<text class="menu-sub">查看与设置默认简历</text>
</view>
<text class="menu-chevron"></text>
</view>
</view>
</view>
<view class="bottom-safe"></view>
</view>

View File

@@ -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;
}

View File

@@ -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()
}
}
})

View File

@@ -0,0 +1,5 @@
{
"navigationBarTitleText": "推广中心",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black"
}

View File

@@ -0,0 +1,219 @@
<!--pages/promo/index.wxml-->
<view class="container">
<!-- 顶部统计卡片 -->
<view class="hero-card">
<view class="hero-header-row">
<view class="hero-left">
<view class="wallet-icon">
<text class="emoji">🧧</text>
</view>
<view class="title-text">
<text class="label">可提现金额</text>
<view class="badge">
<view class="dot"></view>
<text class="badge-text">{{commissionRate}}% 高额返利</text>
</view>
</view>
</view>
<view class="hero-right">
<text class="amount">¥{{balance}}</text>
<text class="amount-sub">累计: ¥{{totalEarned}} | 待审核: ¥{{pendingAmount}}</text>
</view>
</view>
<button class="withdraw-btn {{balance > 0 ? '' : 'disabled'}}" bindtap="handleWithdraw">
<text>{{balance > 0 ? '立即提现' : '暂无提现'}}</text>
</button>
<view class="record-link" bindtap="goToWithdrawHistory">
<text>查看提现记录</text>
<text class="arrow"></text>
</view>
</view>
<!-- 自定义提现金额弹框 -->
<view class="withdraw-mask" wx:if="{{showWithdrawDialog}}">
<view class="withdraw-dialog">
<view class="dialog-title">申请提现</view>
<view class="dialog-sub">可提现 ¥{{balance}},请输入本次提现金额</view>
<view class="amount-input-row">
<text class="currency">¥</text>
<input
class="amount-input"
type="digit"
focus="true"
value="{{withdrawAmountInput}}"
placeholder="至少 1.00 元"
bindinput="onWithdrawInput"
/>
</view>
<view class="amount-hint">
<text>本次最高可提 ¥{{balance}}</text>
</view>
<view class="fee-actual-row" wx:if="{{withdrawFeePct > 0}}">
<text>手续费 ¥{{withdrawFeeYuan}}</text>
<text class="actual">实际到账 ¥{{withdrawActualYuan}}</text>
</view>
<view class="error-text" wx:if="{{withdrawError}}">
<text>{{withdrawError}}</text>
</view>
<view class="dialog-actions">
<view class="btn cancel" bindtap="closeWithdrawDialog">取消</view>
<view class="btn confirm" bindtap="confirmWithdraw">确定</view>
</view>
</view>
</view>
<!-- 四格统计 -->
<view class="stats-grid">
<view class="stat-item">
<text class="stat-val">{{bindingCount}}</text>
<text class="stat-label">绑定中</text>
</view>
<view class="stat-item">
<text class="stat-val">{{paidCount}}</text>
<text class="stat-label">已付款</text>
</view>
<view class="stat-item">
<text class="stat-val highlight">{{expiringCount}}</text>
<text class="stat-label">即将过期</text>
</view>
<view class="stat-item">
<text class="stat-val">{{totalInvite}}</text>
<text class="stat-label">总邀请</text>
</view>
</view>
<!-- 收益规则 -->
<view class="section rule-section">
<view class="section-header">
<view class="header-icon red">
<text class="emoji-sm">🛡️</text>
</view>
<text class="section-title">推广收益规则</text>
</view>
<view class="rule-list">
<view class="rule-item">
<text class="rule-bullet">✨</text>
<text class="rule-text">用户绑定有效期为 <text class="highlight">{{bindingDays}}天</text>,期满自动解除</text>
</view>
<view class="rule-item">
<text class="rule-bullet">💰</text>
<text class="rule-text">单笔提现:最低 <text class="highlight">¥{{withdrawMinYuan}}</text></text>
<text class="rule-text" wx:if="{{withdrawMaxYuan}}">最高 <text class="highlight">¥{{withdrawMaxYuan}}</text></text>
<text class="rule-text" wx:else>不设上限</text>
</view>
<view class="rule-item" wx:if="{{withdrawFeePct > 0}}">
<text class="rule-bullet">📋</text>
<text class="rule-text">提现手续费:<text class="highlight">{{withdrawFeePct}}%</text></text>
</view>
</view>
</view>
<!-- 绑定用户列表 -->
<view class="section user-section">
<view class="section-header border-b">
<view class="header-left">
<text class="emoji-sm">👥</text>
<text class="section-title">绑定用户</text>
<text class="count-label">({{totalInvite}})</text>
</view>
</view>
<!-- Tab 切换 -->
<view class="tab-bar">
<view class="tab-item {{activeTab === 0 ? 'active' : ''}}" bindtap="switchTab" data-index="0">
绑定中 ({{bindingCount}})
</view>
<view class="tab-item {{activeTab === 1 ? 'active' : ''}}" bindtap="switchTab" data-index="1">
已付款 ({{paidCount}})
</view>
<view class="tab-item {{activeTab === 2 ? 'active' : ''}}" bindtap="switchTab" data-index="2">
已过期 ({{expiringCount}})
</view>
</view>
<!-- 空状态 -->
<view class="empty-state" wx:if="{{userList.length === 0}}">
<view class="empty-icon">
<text class="emoji-lg">🔍</text>
</view>
<text class="empty-text">目前还没有绑定的用户哦</text>
<text class="empty-sub">快去分享链接邀请好友吧</text>
</view>
<!-- 用户列表 -->
<view class="user-list" wx:else>
<view class="user-item" wx:for="{{userList}}" wx:key="id">
<!-- 头像 -->
<image wx:if="{{item.avatar}}" class="user-avatar" src="{{item.avatar}}" mode="aspectFill"/>
<view wx:else class="user-avatar user-avatar-placeholder">
<text class="user-avatar-letter">{{item.nickname ? item.nickname[0] : '?'}}</text>
</view>
<!-- 昵称 + 绑定时间 -->
<view class="user-info">
<text class="user-name">{{item.nickname || '微信用户'}}</text>
<text class="user-time">绑定于 {{item.createdAtStr}}</text>
</view>
<!-- 状态 / 剩余天数 -->
<view class="user-badge {{item.status === 'active' ? 'badge-active' : (item.status === 'expired' ? 'badge-expired' : 'badge-paid')}}">
<text wx:if="{{item.status === 'active'}}">{{item.remainDays}}天到期</text>
<text wx:elif="{{item.status === 'expired'}}">已过期</text>
<text wx:else>已付款</text>
</view>
</view>
<!-- 加载更多 / 没有更多 -->
<view class="list-footer" wx:if="{{listLoading}}">
<text class="list-footer-text">加载中...</text>
</view>
<view class="list-footer" wx:elif="{{listFinished && userList.length > 0}}">
<text class="list-footer-text">— 已加载全部 —</text>
</view>
</view>
</view>
<!-- 操作菜单 -->
<view class="menu-card">
<view class="menu-item" bindtap="generatePoster">
<view class="menu-icon-wrap rose">
<text class="emoji-sm">🎨</text>
</view>
<view class="menu-info">
<text class="menu-name">生成推广海报</text>
<text class="menu-desc">一键生成您的专属精美海报</text>
</view>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" bindtap="shareToTimeline">
<view class="menu-icon-wrap emerald">
<text class="emoji-sm">💬</text>
</view>
<view class="menu-info">
<text class="menu-name">分享到朋友圈</text>
<text class="menu-desc">通过右上角菜单分享到朋友圈</text>
</view>
<text class="menu-arrow"></text>
</view>
<button class="menu-item share-btn" open-type="share">
<view class="menu-icon-wrap violet">
<text class="emoji-sm">📤</text>
</view>
<view class="menu-info">
<text class="menu-name">分享给好友</text>
<text class="menu-desc">通过系统面板发送给微信好友</text>
</view>
<text class="menu-arrow"></text>
</button>
</view>
<!-- 底部提示 -->
<view class="footer-tip">
<text>分享专属链接,好友通过链接点击后自动绑定</text>
<text>购买任意测评即可获得 <text class="highlight">90%</text> 的现金返利</text>
</view>
<view class="safe-bottom"></view>
</view>

View File

@@ -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;
}

View File

@@ -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' })
}
}
})
}
})

View File

@@ -0,0 +1,6 @@
{
"navigationBarTitleText": "生成推广海报",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black",
"usingComponents": {}
}

View File

@@ -0,0 +1,16 @@
<view class="container">
<view class="poster-preview">
<image wx:if="{{posterUrl}}" class="poster-img" src="{{posterUrl}}" mode="widthFix" />
<view wx:if="{{loading}}" class="loading-mask">
<view class="loading-spinner"></view>
<text class="loading-text">{{loadingText}}</text>
</view>
</view>
<view class="action-bar">
<button class="save-btn" bindtap="savePoster" disabled="{{loading || !posterUrl}}">
<text>保存海报分享</text>
</button>
<view class="tip-text">保存到相册后可分享至朋友圈</view>
<view class="safe-bottom"></view>
</view>
</view>

View File

@@ -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); }

View File

@@ -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)
}
})

View File

@@ -0,0 +1,5 @@
{
"navigationBarTitleText": "提现记录",
"usingComponents": {}
}

View File

@@ -0,0 +1,77 @@
<!-- pages/promo/withdrawals.wxml - 提现记录列表 -->
<view class="page">
<!-- 顶部说明 -->
<!-- 空状态 -->
<view class="empty" wx:if="{{!loading && list.length === 0}}">
<view class="empty-icon">
<text>📭</text>
</view>
<text class="empty-text">暂无提现记录</text>
<text class="empty-sub">成功发起提现后,将在这里展示进度</text>
</view>
<!-- 列表(参考碎片时间提现列表样式结构) -->
<view class="list" wx:else>
<view class="item" wx:for="{{list}}" wx:key="id">
<!-- 顶部:单号 + 状态标签 -->
<view class="item-header">
<text class="order-no">单号:{{item.out_bill_no}}</text>
<view class="status-tag {{item.statusTagClass}}">
<text>{{item.statusTagText}}</text>
</view>
</view>
<!-- 中部:金额 + 手续费 + 提现后余额 -->
<view class="item-top">
<view class="amount-block">
<text class="amount">¥{{item.amountYuan}}</text>
</view>
<view class="fee-block" wx:if="{{item.feeYuan && item.feeYuan !== '0.00'}}">
<text class="fee-label">手续费</text>
<text class="fee-value">¥{{item.feeYuan}}</text>
</view>
<view class="balance-block" wx:if="{{item.balanceAfterYuan}}">
<text class="balance-label">提现后余额</text>
<text class="balance-value">¥{{item.balanceAfterYuan}}</text>
</view>
</view>
<!-- 时间信息 -->
<view class="time-block">
<text class="time-row">申请时间:{{item.applyAtStr}}</text>
<text class="time-row" wx:if="{{item.handleAtStr}}">处理时间:{{item.handleAtStr}}</text>
</view>
<!-- 拒绝原因 -->
<view class="reject-box" wx:if="{{item.status === 'rejected' && item.auditNote}}">
<text class="reject-title">拒绝原因:</text>
<text class="reject-text">{{item.auditNote}}</text>
</view>
<!-- 每条记录自己的确认收款按钮:只有「待收款」(statusCode=2) 需要用户确认 -->
<view class="confirm-wrapper" wx:if="{{item.statusCode === 2}}">
<button
class="confirm-btn"
bindtap="handleConfirmReceipt"
data-id="{{item.id}}"
loading="{{confirmingId === item.id}}"
disabled="{{confirmingId === item.id}}"
>
确认收款
</button>
</view>
</view>
<view class="footer" wx:if="{{loading}}">
<text class="footer-text">加载中...</text>
</view>
<view class="footer" wx:elif="{{finished}}">
<text class="footer-text">— 已加载全部 —</text>
</view>
</view>
<view class="safe-bottom"></view>
</view>

View File

@@ -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));
}

View File

@@ -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()
}
}
})

View File

@@ -0,0 +1,6 @@
{
"navigationBarTitleText": "开通会员",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black",
"usingComponents": {}
}

View File

@@ -0,0 +1,135 @@
<!--pages/purchase/index.wxml - 开通会员(深度服务类目由接口拉取,个人/企业区分)-->
<!-- 成功弹窗 -->
<view class="success-mask" wx:if="{{successModal.visible}}" bindtap="closeSuccessModal">
<view class="success-dialog" catchtap="catchTap">
<view class="success-icon-wrap">
<view class="success-icon-circle">
<text class="success-icon-check">✓</text>
</view>
</view>
<text class="success-dialog-title">{{successModal.title}}</text>
<text class="success-dialog-content">{{successModal.content}}</text>
<view wx:if="{{successModal.wechat}}" class="success-wechat-wrap">
<text class="success-wechat-label">客服微信</text>
<text class="success-wechat-val">{{successModal.wechat}}</text>
</view>
<view class="success-dialog-btns">
<button wx:if="{{successModal.wechat}}" class="success-btn-copy" bindtap="copyWechat">复制微信号</button>
<button class="success-btn-close" bindtap="closeSuccessModal">我知道了</button>
</view>
</view>
</view>
<view class="container">
<view wx:if="{{loading}}" class="loading-wrap">
<text class="loading-text">加载中...</text>
</view>
<!-- 个人版:按类目列表渲染 -->
<view wx:elif="{{activeTab === 'personal'}}">
<view wx:for="{{personalCategories}}" wx:key="id" class="pricing-card {{index === 0 ? 'featured' : ''}}" style="margin: 24rpx;">
<view class="card-header">
<text class="plan-title">{{item.title}}</text>
<view class="price-section" wx:if="{{item.price !== undefined}}">
<text class="price-symbol">¥</text>
<text class="price-amount">{{item.price}}</text>
<text class="price-unit">{{item.priceUnit || '/次'}}</text>
</view>
<text class="plan-subtitle" wx:if="{{item.subtitle}}">{{item.subtitle}}</text>
</view>
<view class="features-list" wx:if="{{item.features && item.features.length}}">
<view class="feature-item" wx:for="{{item.features}}" wx:for-item="f" wx:key="*this">
<view class="check-icon">✓</view>
<text class="feature-text">{{f}}</text>
</view>
</view>
<!-- 未有手机号时:使用微信系统手机号授权组件 -->
<button
class="purchase-button primary"
wx:if="{{item.actionType === 'buy' && !hasPhone}}"
open-type="getPhoneNumber"
bindgetphonenumber="onGetPhoneNumberForPurchase"
data-tab="personal"
data-index="{{index}}"
>
<text class="button-text">立即购买</text>
</button>
<!-- 已有手机号时:普通按钮,直接走支付 -->
<button
class="purchase-button primary"
wx:elif="{{item.actionType === 'buy' && hasPhone}}"
bindtap="handlePurchaseTap"
data-tab="personal"
data-index="{{index}}"
>
<text class="button-text">立即购买</text>
</button>
</view>
<view wx:if="{{!loading && activeTab === 'personal' && personalCategories.length === 0}}" class="empty-tip">
<text>暂无可用的个人版套餐</text>
</view>
</view>
<!-- 企业版:按类目列表渲染 -->
<view wx:elif="{{activeTab === 'enterprise'}}">
<view wx:for="{{enterpriseCategories}}" wx:key="id" class="pricing-card {{index === 1 ? 'featured-blue' : ''}}" style="margin: 24rpx;">
<view class="card-header-flex">
<view class="header-left">
<text class="plan-title-sm">{{item.title}}</text>
<text class="plan-desc" wx:if="{{item.subtitle}}">{{item.subtitle}}</text>
</view>
<view class="header-right" wx:if="{{item.priceDisplay || item.price}}">
<text class="price-amount-small">{{item.priceDisplay || '¥' + item.price}}</text>
<text class="price-limit" wx:if="{{item.userLimit}}">{{item.userLimit}}</text>
</view>
</view>
<view class="features-list" wx:if="{{item.features && item.features.length}}">
<view class="feature-item" wx:for="{{item.features}}" wx:for-item="f" wx:key="*this">
<view class="check-icon blue">✓</view>
<text class="feature-text">{{f}}</text>
</view>
</view>
<!-- 未有手机号时:使用微信系统手机号授权组件 -->
<button
class="purchase-button secondary"
wx:if="{{!hasPhone}}"
open-type="getPhoneNumber"
bindgetphonenumber="onGetPhoneNumberForPurchase"
data-tab="enterprise"
data-index="{{index}}"
>
<text class="button-text">{{item.buttonText || '申请咨询'}}</text>
</button>
<!-- 已有手机号时:普通按钮,直接触发咨询逻辑 -->
<button
class="purchase-button secondary"
wx:elif="{{hasPhone}}"
bindtap="handlePurchaseTap"
data-tab="enterprise"
data-index="{{index}}"
>
<text class="button-text">{{item.buttonText || '申请咨询'}}</text>
</button>
</view>
<view wx:if="{{!loading && activeTab === 'enterprise' && enterpriseCategories.length === 0}}" class="empty-tip">
<text>暂无可用的企业版套餐</text>
</view>
</view>
<view class="safety-tips">
<view class="safety-item">
<text class="safety-icon">🔒</text>
<text class="safety-text">微信安全支付</text>
</view>
<view class="safety-item">
<text class="safety-icon">✅</text>
<text class="safety-text">即时到账开通</text>
</view>
<view class="safety-item">
<text class="safety-icon">📞</text>
<text class="safety-text">7×24客服</text>
</view>
</view>
</view>

View File

@@ -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;
}

View File

@@ -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 })
}
})
}
})

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "企业余额充值"
}

View File

@@ -0,0 +1,20 @@
<view class="recharge-page">
<view class="card">
<view class="title">企业余额充值</view>
<view class="subtitle">扫码后在小程序内完成支付,支付成功后自动进入企业余额</view>
<view class="amount-box">
<text class="amount-label">充值金额</text>
<text class="amount-value">¥{{amountYuan}}</text>
</view>
<view class="meta-row">
<text class="meta-label">企业名称</text>
<text class="meta-value">{{enterpriseName || ('企业 #' + enterpriseId)}}</text>
</view>
<button class="pay-btn" loading="{{paying}}" disabled="{{paying}}" bindtap="submitRecharge">
<text class="pay-btn-text">{{paying ? '支付中...' : '立即充值'}}</text>
</button>
</view>
</view>

View File

@@ -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;
}

View File

@@ -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()
}
}
})

View File

@@ -0,0 +1,6 @@
{
"navigationBarTitleText": "DISC结果",
"navigationBarBackgroundColor": "#3B82F6",
"navigationBarTextStyle": "white",
"usingComponents": {}
}

View File

@@ -0,0 +1,81 @@
<!--pages/result/disc.wxml - DISC结果按旧版模板重构-->
<view class="result-page">
<scroll-view class="content-scroll" scroll-y>
<view class="content-container">
<view class="type-card">
<view class="type-header">
<text class="type-label">您的DISC性格类型</text>
<text class="type-value">{{result.dominantType}}{{result.secondaryType ? ' + ' + result.secondaryType : ''}}</text>
<text class="type-description" wx:if="{{result.description && result.description.description}}">{{result.description.description}}</text>
</view>
<view class="paywall-card" wx:if="{{payInfo.requiresPayment && !payInfo.isPaid}}">
<view class="paywall-content">
<view class="paywall-blur">
<text class="paywall-fake-title">完整DISC报告</text>
<text class="paywall-fake-line">• 四维得分详情</text>
<text class="paywall-fake-line">• 性格特征与优劣势</text>
<text class="paywall-fake-line">• 职业匹配建议</text>
</view>
<view class="paywall-mask"></view>
<view class="paywall-btn" bindtap="unlockFullReport">
<text class="paywall-btn-main">解锁完整报告</text>
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
</view>
</view>
</view>
<view class="scores-section" wx:if="{{!payInfo.requiresPayment || payInfo.isPaid}}">
<text class="section-title">DISC得分详情</text>
<view class="score-item" wx:for="{{typeList}}" wx:key="type">
<view class="score-header">
<text class="score-label">{{item.label}}</text>
<text class="score-value">{{result.percentagesInt[item.type] != null ? result.percentagesInt[item.type] + '%' : '0%'}}</text>
</view>
<view class="progress-bar-container">
<view class="progress-bar {{item.colorClass}}" style="width: {{result.percentages[item.type]}}%"></view>
</view>
</view>
</view>
</view>
<view class="analysis-card" wx:if="{{!payInfo.requiresPayment || payInfo.isPaid}}">
<text class="card-title">主要性格特征分析</text>
<view class="trait-section">
<text class="trait-title">优势</text>
<view class="trait-item" wx:for="{{result.description.strengths}}" wx:key="*this">
<text class="trait-bullet">•</text>
<text class="trait-text">{{item}}</text>
</view>
</view>
<view class="trait-section">
<text class="trait-title">需要注意的方面</text>
<view class="trait-item" wx:for="{{result.description.weaknesses}}" wx:key="*this">
<text class="trait-bullet">•</text>
<text class="trait-text">{{item}}</text>
</view>
</view>
</view>
<view class="analysis-card" wx:if="{{!payInfo.requiresPayment || payInfo.isPaid}}">
<text class="card-title">职业匹配度分析</text>
<view class="career-item" wx:for="{{result.description.careers}}" wx:key="*this">
<view class="career-dot"></view>
<text class="career-text">{{item}}</text>
</view>
</view>
<view class="action-section">
<button class="btn btn-primary" open-type="share">
<text class="btn-text">分享结果</text>
</button>
<view class="btn btn-outline" bindtap="retakeTest">
<text class="btn-text-outline">重新测试</text>
</view>
<view class="btn btn-outline" bindtap="goHome">
<text class="btn-text-outline">返回首页</text>
</view>
</view>
</view>
</scroll-view>
</view>

View File

@@ -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; }

View File

@@ -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()
}
}
})

View File

@@ -0,0 +1,6 @@
{
"navigationBarTitleText": "MBTI结果",
"navigationBarBackgroundColor": "#FF6B8A",
"navigationBarTextStyle": "white",
"usingComponents": {}
}

View File

@@ -0,0 +1,107 @@
<!--pages/result/mbti.wxml - MBTI结果页面支持付费墙-->
<view class="result-page">
<scroll-view class="content-scroll" scroll-y>
<view class="content-container">
<view class="type-card">
<view class="type-header">
<text class="type-label">您的MBTI性格类型</text>
<text class="type-value">{{result.mbtiType}}</text>
<text class="type-title">{{mbtiDesc.title}}</text>
<text class="type-description">{{mbtiDesc.description}}</text>
</view>
<!-- 付费墙:未解锁时显示 -->
<view class="paywall-card" wx:if="{{payInfo.requiresPayment && !payInfo.isPaid}}">
<view class="paywall-content">
<view class="paywall-blur">
<text class="paywall-fake-title">完整性格分析</text>
<text class="paywall-fake-line">• 四维得分与主导倾向</text>
<text class="paywall-fake-line">• 优势与需要注意的方面</text>
<text class="paywall-fake-line">• 职业匹配与人际关系建议</text>
</view>
<view class="paywall-mask"></view>
<!-- 未有手机号:使用微信系统手机号授权 -->
<button
class="paywall-btn"
wx:if="{{!hasPhone}}"
open-type="getPhoneNumber"
bindgetphonenumber="onGetPhoneNumberForMbtiPay"
>
<text class="paywall-btn-main">解锁完整报告</text>
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
</button>
<!-- 已有手机号:普通按钮,直接解锁 -->
<button
class="paywall-btn"
wx:elif="{{hasPhone}}"
bindtap="unlockFullReport"
>
<text class="paywall-btn-main">解锁完整报告</text>
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
</button>
</view>
</view>
<view class="dimensions-section" wx:if="{{!payInfo.requiresPayment || payInfo.isPaid}}">
<view class="dimension-item" wx:for="{{dimensions}}" wx:key="key">
<view class="dimension-labels">
<text class="label-left">{{item.left}}</text>
<text class="label-right">{{item.right}}</text>
</view>
<view class="progress-bar-container">
<view class="progress-bar" style="width: {{item.percentage}}%"></view>
</view>
<view class="dimension-values">
<text class="value-left">{{item.dominant}}</text>
<text class="value-right">{{item.percentage}}%</text>
</view>
</view>
</view>
</view>
<view class="analysis-card" wx:if="{{!payInfo.requiresPayment || payInfo.isPaid}}">
<text class="card-title">性格特征分析</text>
<view class="trait-section">
<text class="trait-title">优势</text>
<view class="trait-item" wx:for="{{mbtiDesc.strengths}}" wx:key="*this">
<text class="trait-bullet">•</text>
<text class="trait-text">{{item}}</text>
</view>
</view>
<view class="trait-section">
<text class="trait-title">需要注意的方面</text>
<view class="trait-item" wx:for="{{mbtiDesc.weaknesses}}" wx:key="*this">
<text class="trait-bullet">•</text>
<text class="trait-text">{{item}}</text>
</view>
</view>
</view>
<view class="analysis-card" wx:if="{{!payInfo.requiresPayment || payInfo.isPaid}}">
<text class="card-title">职业匹配度分析</text>
<view class="career-item" wx:for="{{mbtiDesc.careers}}" wx:key="*this">
<view class="career-dot"></view>
<text class="career-text">{{item}}</text>
</view>
</view>
<view class="analysis-card" wx:if="{{(!payInfo.requiresPayment || payInfo.isPaid) && mbtiDesc.relationships}}">
<text class="card-title">人际关系分析</text>
<text class="relationship-text">{{mbtiDesc.relationships}}</text>
</view>
<view class="action-section">
<button class="btn btn-primary" open-type="share">
<text class="btn-text">分享结果</text>
</button>
<view class="btn btn-outline" bindtap="retakeTest">
<text class="btn-text-outline">重新测试</text>
</view>
<view class="btn btn-outline" bindtap="goHome">
<text class="btn-text-outline">返回首页</text>
</view>
</view>
</view>
</scroll-view>
</view>

View File

@@ -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; }

View File

@@ -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()
}
}
})

View File

@@ -0,0 +1,6 @@
{
"navigationBarTitleText": "PDP结果",
"navigationBarBackgroundColor": "#F59E0B",
"navigationBarTextStyle": "white",
"usingComponents": {}
}

View File

@@ -0,0 +1,87 @@
<!--pages/result/pdp.wxml - PDP结果按旧版模板重构-->
<view class="result-page">
<scroll-view class="content-scroll" scroll-y>
<view class="content-container">
<view class="type-card">
<view class="type-header">
<text class="type-label">PDP性格类型</text>
<text class="type-value">{{result.description.type || result.dominantType}}{{result.description.emoji || ''}}</text>
<text class="type-title" wx:if="{{result.description.title}}">{{result.description.title}}</text>
<text class="type-description" wx:if="{{result.description.description}}">{{result.description.description}}</text>
</view>
<view class="paywall-card" wx:if="{{payInfo.requiresPayment && !payInfo.isPaid}}">
<view class="paywall-content">
<view class="paywall-blur">
<text class="paywall-fake-title">完整PDP报告</text>
<text class="paywall-fake-line">• 五维得分详情</text>
<text class="paywall-fake-line">• 性格特征与团队角色</text>
<text class="paywall-fake-line">• 推荐职业</text>
</view>
<view class="paywall-mask"></view>
<view class="paywall-btn" bindtap="unlockFullReport">
<text class="paywall-btn-main">解锁完整报告</text>
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
</view>
</view>
</view>
<view class="scores-section" wx:if="{{!payInfo.requiresPayment || payInfo.isPaid}}">
<text class="section-title">PDP得分详情</text>
<view class="score-item" wx:for="{{typeList}}" wx:key="type">
<view class="score-header">
<text class="score-label">{{item.emoji}} {{item.label}}</text>
<text class="score-value">{{result.percentagesInt && result.percentagesInt[item.type] != null ? result.percentagesInt[item.type] + '%' : '0%'}}</text>
</view>
<view class="progress-bar-container">
<view class="progress-bar {{item.colorClass}}" style="width: {{result.percentages[item.type]}}%"></view>
</view>
</view>
</view>
</view>
<view class="analysis-card" wx:if="{{!payInfo.requiresPayment || payInfo.isPaid}}">
<text class="card-title">性格特征</text>
<view class="trait-section">
<text class="trait-title">优势</text>
<view class="trait-item" wx:for="{{result.description.strengths}}" wx:key="*this">
<text class="trait-bullet">•</text>
<text class="trait-text">{{item}}</text>
</view>
</view>
<view class="trait-section">
<text class="trait-title">需要注意的方面</text>
<view class="trait-item" wx:for="{{result.description.weaknesses}}" wx:key="*this">
<text class="trait-bullet">•</text>
<text class="trait-text">{{item}}</text>
</view>
</view>
</view>
<view class="analysis-card" wx:if="{{!payInfo.requiresPayment || payInfo.isPaid}}">
<text class="card-title">团队角色</text>
<text class="relationship-text">{{result.description.teamRole}}</text>
</view>
<view class="analysis-card" wx:if="{{!payInfo.requiresPayment || payInfo.isPaid}}">
<text class="card-title">推荐职业</text>
<view class="career-item" wx:for="{{result.description.careers}}" wx:key="*this">
<view class="career-dot"></view>
<text class="career-text">{{item}}</text>
</view>
</view>
<view class="action-section">
<button class="btn btn-primary" open-type="share">
<text class="btn-text">分享结果</text>
</button>
<view class="btn btn-outline" bindtap="retakeTest">
<text class="btn-text-outline">重新测试</text>
</view>
<view class="btn btn-outline" bindtap="goHome">
<text class="btn-text-outline">返回首页</text>
</view>
</view>
</view>
</scroll-view>
</view>

View File

@@ -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; }

View File

@@ -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 '📝';
}
})

View File

@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "简历综合分析"
}

View File

@@ -0,0 +1,86 @@
<!--pages/result/resume.wxml - 简历综合分析结果页 -->
<view class="container">
<!-- 分析中状态 -->
<view class="analyzing-modal" wx:if="{{loading && !error}}">
<view class="analyzing-content">
<view class="analyzing-icon">
<view class="spinner"></view>
</view>
<text class="analyzing-title">{{analyzingTitle || '正在分析中'}}</text>
<text class="analyzing-desc">{{analyzingTip}}</text>
<view class="analyzing-bar">
<view class="analyzing-bar-fill" style="width: {{progress}}%"></view>
</view>
<text class="analyzing-hint">这个过程可能需要30秒到1分钟请耐心等待...</text>
</view>
</view>
<!-- 结果显示 -->
<block wx:if="{{!loading || error || content}}">
<!-- 页面顶部 Hero -->
<view class="hero-banner {{error ? 'hero-error' : 'hero-success'}}">
<text class="hero-icon">{{error ? '⚠️' : '📋'}}</text>
<view class="hero-text-wrap">
<text class="hero-title">{{error ? '生成失败' : '人才综合评估报告'}}</text>
<text class="hero-sub">{{error ? '请检查后重新生成' : '基于测评结果 · AI 综合分析'}}</text>
</view>
</view>
<!-- 异常状态 -->
<view class="card error-card" wx:if="{{error}}">
<text class="card-title error-title">失败原因</text>
<text class="card-text error-message">{{error}}</text>
<view class="action-btn-retry" bindtap="fetchResumeAnalysis">
<text class="retry-text">点击重新生成</text>
</view>
</view>
<!-- 需要付费解锁 -->
<view class="card pay-gate-card" wx:if="{{payInfo.requiresPayment && !payInfo.isPaid && !error}}">
<text class="pay-gate-icon">🔒</text>
<text class="pay-gate-title">报告已生成,需付费解锁</text>
<text class="pay-gate-desc">完整的简历综合分析报告已生成\n支付后即可查看全部内容</text>
<view class="pay-gate-amount">
<text class="pay-amount-label">解锁价格</text>
<text class="pay-amount-value">¥{{payInfo.amountYuan}}</text>
</view>
<view class="pay-gate-btn {{paying ? 'paying' : ''}}" bindtap="doPay">
<text class="pay-gate-btn-text">{{paying ? '支付中...' : '立即支付解锁'}}</text>
</view>
</view>
<!-- 成功内容:分块卡片展示 -->
<block wx:if="{{content && sections.length > 0 && !payInfo.requiresPayment}}">
<view class="card section-card" wx:for="{{sections}}" wx:key="index">
<view class="section-header">
<text class="section-icon">{{item.icon}}</text>
<text class="section-title-text">{{item.title}}</text>
</view>
<view class="section-body">
<text class="content-text">{{item.body}}</text>
</view>
</view>
</block>
<!-- 兜底显示:解析失败时显示原始内容 -->
<view class="card" wx:if="{{content && sections.length === 0 && !payInfo.requiresPayment}}">
<text class="card-title">综合分析综评</text>
<text class="card-text content-text">{{content}}</text>
</view>
<!-- 操作按钮 -->
<view class="action-section" wx:if="{{!loading || error}}">
<button class="btn btn-primary" open-type="share" wx:if="{{!payInfo.requiresPayment && content}}">
<text class="btn-text">分享分析报告</text>
</button>
<view class="btn btn-outline" bindtap="fetchResumeAnalysis" wx:if="{{!loading && content && !payInfo.requiresPayment}}">
<text class="btn-text-outline">重新分析生成</text>
</view>
<view class="btn btn-outline" bindtap="goHome">
<text class="btn-text-outline">返回首页</text>
</view>
</view>
</block>
</view>

View File

@@ -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;
}

Some files were not shown because too many files have changed in this diff Show More