feat: 小程序超级个体/个人资料/CKB获客;VIP列表展示过滤;管理端与API联调

- 超级个体:去掉首位特例;列表仅展示有头像且非微信默认昵称(vip.go)
- 个人资料:居中头像、低调联系方式、点头像优先走存客宝 lead(ckbLeadToken)
- 阅读页分享朋友圈复制与 toast 去重
- soul-api: miniprogram users 带 ckbLeadToken;其它 handler 与路由调整
- 脚本:content_upload、miniprogram 上传辅助等

Made-with: Cursor
This commit is contained in:
卡若
2026-03-22 08:34:28 +08:00
parent 17ce20c8ee
commit 5724fba877
119 changed files with 8198 additions and 4369 deletions

View File

@@ -9,13 +9,16 @@ const { checkAndExecute } = require('./utils/ruleEngine.js')
const DEFAULT_APP_ID = 'wxb8bbb2b10dec74aa'
const DEFAULT_MCH_ID = '1318592501'
const DEFAULT_WITHDRAW_TMPL_ID = 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE'
// 与上传版本号对齐;设置页展示优先用 wx.getAccountInfoSync().miniProgram.version正式版否则用本字段
const APP_DISPLAY_VERSION = '1.7.1'
App({
globalData: {
// API 基础地址:开发时修改下面一行切换环境
// baseUrl: "https://soulapi.quwanzhi.com",
baseUrl: 'http://localhost:8080', // 开发
// baseUrl: 'https://souldev.quwanzhi.com', // 测试
// 与微信后台上传版本号一致,供设置页等展示(避免与线上 version 字段混淆)
appDisplayVersion: APP_DISPLAY_VERSION,
// API仓库默认生产release 强制生产develop/trial 可读 storage「apiBaseUrl」或用 env-switch
baseUrl: 'https://soulapi.quwanzhi.com',
// 小程序配置 - 真实AppID
appId: DEFAULT_APP_ID,
@@ -30,9 +33,14 @@ App({
openId: null, // 微信openId支付必需
isLoggedIn: false,
// 阅读页 @ 解析:/config/read-extras 的 mentionPersons与后台 persons + token 一致)
mentionPersons: [],
// 是否已成功拉取过 read-extras避免仅 linkTags 有缓存时永远拿不到 mentionPersons
readExtrasCacheValid: false,
// 书籍数据bookData 由 chapters-by-part 等逐步填充,不再预加载 all-chapters
bookData: null,
totalSections: 62,
totalSections: 90,
// 购买记录
purchasedSections: [],
@@ -47,6 +55,9 @@ App({
// 推荐绑定
pendingReferralCode: null, // 待绑定的推荐码
// 客服微信号(从系统配置加载,默认值兜底)
serviceWechat: '28533368',
// 主题配置
theme: {
brandColor: '#00CED1',
@@ -81,14 +92,39 @@ App({
// config 统一缓存5min减少重复请求
configCache: null,
configCacheExpires: 0,
// VIP 联系方式检测上次检测时间戳onShow 节流 5 分钟
// VIP 联系方式检测上次检测时间戳onShow 节流(避免与 launch 重复打满接口)
lastVipContactCheck: 0,
// 头像昵称检测:上次检测时间戳onShow 节流 5 分钟
// 头像昵称检测:上次检测时间戳(与 VIP 检测同周期刷新)
lastAvatarNicknameCheck: 0,
},
/** 正式版强制生产 API避免误传 localhost 导致审核/线上全挂 */
initApiBaseUrl() {
const PRODUCTION = 'https://soulapi.quwanzhi.com'
const KEY = 'apiBaseUrl'
try {
const info = wx.getAccountInfoSync?.()
const env = info?.miniProgram?.envVersion || 'release'
if (env === 'release') {
this.globalData.baseUrl = PRODUCTION
try {
const saved = wx.getStorageSync(KEY)
if (saved && saved !== PRODUCTION) wx.removeStorageSync(KEY)
} catch (_) {}
return
}
const saved = wx.getStorageSync(KEY)
if (saved && typeof saved === 'string' && /^https?:\/\//.test(saved)) {
this.globalData.baseUrl = String(saved).replace(/\/$/, '')
}
} catch (_) {
this.globalData.baseUrl = PRODUCTION
}
},
onLaunch(options) {
this.initApiBaseUrl()
// 昵称等隐私组件需先授权input type="nickname" 不会主动触发,需配合 wx.requirePrivacyAuthorize 使用
if (typeof wx.onNeedPrivacyAuthorization === 'function') {
wx.onNeedPrivacyAuthorization((resolve) => {
@@ -169,10 +205,10 @@ App({
this.globalData.lastMpConfigCheck = now
this.getAuditMode()
}
// 从后台切回: VIP 强制跳转,再头像/昵称,节流 5 分钟
const throttle = 5 * 60 * 1000
// 从后台切回:刷新 VIP/头像引导vipGuideThrottleMs=0 表示不限制间隔)
const vipGuideThrottleMs = 0
if (this.globalData.isLoggedIn && this.globalData.userInfo?.id) {
if (!this.globalData.lastVipContactCheck || now - this.globalData.lastVipContactCheck > throttle) {
if (vipGuideThrottleMs <= 0 || !this.globalData.lastVipContactCheck || now - this.globalData.lastVipContactCheck > vipGuideThrottleMs) {
this.globalData.lastVipContactCheck = now
this.globalData.lastAvatarNicknameCheck = now
setTimeout(() => this.checkVipContactRequiredAndGuide(), 500)
@@ -558,9 +594,6 @@ App({
*/
async checkVipContactRequiredAndGuide() {
if (!this.globalData.isLoggedIn || !this.globalData.userInfo?.id) return
const now = Date.now()
if (this._lastVipGuideRun && now - this._lastVipGuideRun < 3000) return // 3 秒内不重复执行,避免 onLaunch+onShow 双重触发
this._lastVipGuideRun = now
const userId = this.globalData.userInfo.id
try {
const pages = getCurrentPages()
@@ -688,10 +721,11 @@ App({
* 获取阅读页扩展配置linkTags、linkedMiniprograms懒加载
*/
async getReadExtras() {
if (Array.isArray(this.globalData.linkTagsConfig) && this.globalData.linkTagsConfig.length > 0) {
if (this.globalData.readExtrasCacheValid) {
return {
linkTags: this.globalData.linkTagsConfig,
linkedMiniprograms: this.globalData.linkedMiniprograms || []
linkTags: this.globalData.linkTagsConfig || [],
linkedMiniprograms: this.globalData.linkedMiniprograms || [],
mentionPersons: this.globalData.mentionPersons || [],
}
}
try {
@@ -699,10 +733,18 @@ App({
if (res) {
if (Array.isArray(res.linkTags)) this.globalData.linkTagsConfig = res.linkTags
if (Array.isArray(res.linkedMiniprograms)) this.globalData.linkedMiniprograms = res.linkedMiniprograms
if (Array.isArray(res.mentionPersons)) this.globalData.mentionPersons = res.mentionPersons
else this.globalData.mentionPersons = []
this.globalData.readExtrasCacheValid = true
return res
}
} catch (e) {}
return { linkTags: [], linkedMiniprograms: [] }
if (!Array.isArray(this.globalData.mentionPersons)) this.globalData.mentionPersons = []
return {
linkTags: this.globalData.linkTagsConfig || [],
linkedMiniprograms: this.globalData.linkedMiniprograms || [],
mentionPersons: this.globalData.mentionPersons,
}
},
/**
@@ -759,14 +801,14 @@ App({
/**
* 小程序更新检测(基于 wx.getUpdateManager
* - 启动时检测;从后台切回前台时也检测(间隔至少 5 分钟,避免频繁请求
* - 启动时检测;从后台切回前台时也检测(间隔即可,避免用户感知「很久才检查更新」
*/
checkUpdate() {
try {
if (!wx.canIUse('getUpdateManager')) return
const now = Date.now()
const lastCheck = this.globalData.lastUpdateCheck || 0
if (lastCheck && now - lastCheck < 5 * 60 * 1000) return // 5 分钟内不重复检测
if (lastCheck && now - lastCheck < 60 * 1000) return // 1 分钟内不重复检测
this.globalData.lastUpdateCheck = now
const updateManager = wx.getUpdateManager()
@@ -1038,13 +1080,6 @@ App({
return null
},
// 模拟登录已废弃 - 不再使用
// 现在必须使用真实的微信登录获取openId作为唯一标识
mockLogin() {
console.warn('[App] mockLogin已废弃请使用真实登录')
return null
},
// 手机号登录:需同时传 wx.login 的 code 与 getPhoneNumber 的 phoneCode
async loginWithPhone(phoneCode) {
if (!this.ensureFullAppForAuth()) {

View File

@@ -29,8 +29,7 @@
"pages/avatar-nickname/avatar-nickname",
"pages/gift-pay/detail",
"pages/gift-pay/list",
"pages/gift-pay/redemption-detail",
"pages/dev-login/dev-login"
"pages/gift-pay/redemption-detail"
],
"window": {
"backgroundTextStyle": "light",

View File

@@ -0,0 +1,8 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="8" y1="6" x2="21" y2="6" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="8" y1="12" x2="21" y2="12" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="8" y1="18" x2="21" y2="18" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="3" y1="6" x2="3.01" y2="6" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="3" y1="12" x2="3.01" y2="12" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="3" y1="18" x2="3.01" y2="18" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 814 B

View File

@@ -0,0 +1,7 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="18" cy="5" r="3" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="6" cy="12" r="3" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="18" cy="19" r="3" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 690 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<g stroke="#00CED1" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round" opacity="0.36">
<path d="M7 10.5V8a5 5 0 0 1 9.6-2"/>
<rect x="5" y="10.5" width="14" height="10.5" rx="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 335 B

View File

@@ -0,0 +1,4 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 419 B

View File

@@ -29,6 +29,9 @@ Page({
// 已加载的篇章章节缓存 { partId: chapters }
_loadedChapters: {},
// 小三角点击动画:当前触发的子章 id与 chapter.id 比对)
_triangleAnimating: '',
// 固定模块 id -> mid序言/尾声/附录,供 goToRead 传 mid
fixedSectionsMap: {},
@@ -152,6 +155,12 @@ Page({
})
})
const chapters = Array.from(chMap.values())
chapters.forEach(ch => ch.sections.reverse())
// 目录子章下列表:默认最多展示 5 条,点小三角每次再展开 5 条
chapters.forEach((ch) => {
const n = ch.sections.length
ch.sectionVisibleLimit = n === 0 ? 0 : Math.min(5, n)
})
const loaded = { ...this.data._loadedChapters, [partId]: chapters }
const bookData = this.data.bookData.map(p =>
p.id === partId ? { ...p, chapters } : p
@@ -227,6 +236,43 @@ Page({
if (isExpanding) await this.loadChaptersByPart(partId)
},
expandSectionChapter(e) {
const partId = e.currentTarget.dataset.partId
const chapterId = e.currentTarget.dataset.chapterId
if (!partId || !chapterId) return
trackClick('chapters', 'tab_click', '目录_子章展开5条')
const part = this.data.bookData.find((p) => p.id === partId)
const chapter = part && (part.chapters || []).find((c) => c.id === chapterId)
if (!chapter || !chapter.sections || chapter.sections.length === 0) return
const total = chapter.sections.length
const cur = typeof chapter.sectionVisibleLimit === 'number' ? chapter.sectionVisibleLimit : Math.min(5, total)
const next = Math.min(cur + 5, total)
if (next === cur) return
const bookData = this.data.bookData.map((p) => {
if (p.id !== partId) return p
return {
...p,
chapters: (p.chapters || []).map((ch) =>
ch.id === chapterId ? { ...ch, sectionVisibleLimit: next } : ch
),
}
})
// 先去掉动画 class 再打上,便于连续点击重复触发动画
this.setData({ _triangleAnimating: '', bookData })
setTimeout(() => {
this.setData({ _triangleAnimating: chapterId })
setTimeout(() => {
if (this.data._triangleAnimating === chapterId) {
this.setData({ _triangleAnimating: '' })
}
}, 480)
}, 30)
},
// 跳转到阅读页(优先传 mid与分享逻辑一致
goToRead(e) {
const id = e.currentTarget.dataset.id

View File

@@ -88,8 +88,8 @@
<block wx:for="{{item.chapters}}" wx:key="id" wx:for-item="chapter">
<view class="chapter-header">{{chapter.title}}</view>
<view class="section-list">
<block wx:for="{{chapter.sections}}" wx:key="id" wx:for-item="section">
<view class="section-item" bindtap="goToRead" data-id="{{section.id}}" data-mid="{{section.mid}}">
<block wx:for="{{chapter.sections}}" wx:key="id" wx:for-item="section" wx:for-index="secIdx">
<view class="section-item" wx:if="{{secIdx < chapter.sectionVisibleLimit}}" bindtap="goToRead" data-id="{{section.id}}" data-mid="{{section.mid}}">
<view class="section-left">
<view class="section-lock-wrap">
<icon wx:if="{{section.isFree || isVip || (!section.isPremium && hasFullBook) || purchasedSections.indexOf(section.id) > -1}}" name="lock-open" size="24" color="#00CED1" customClass="section-lock lock-open"></icon>
@@ -100,12 +100,14 @@
</view>
<view class="section-right">
<text wx:if="{{section.isFree}}" class="tag tag-free">免费</text>
<text wx:elif="{{isVip || (!section.isPremium && hasFullBook) || purchasedSections.indexOf(section.id) > -1}}" class="tag tag-purchased">已解锁</text>
<text wx:else class="section-price">¥{{section.price}}</text>
<text wx:elif="{{!(isVip || (!section.isPremium && hasFullBook) || purchasedSections.indexOf(section.id) > -1)}}" class="section-price">¥{{section.price}}</text>
<icon name="chevron-right" size="24" color="rgba(255,255,255,0.3)" customClass="section-arrow"></icon>
</view>
</view>
</block>
<view class="section-expand-trigger" wx:if="{{chapter.sections.length > chapter.sectionVisibleLimit}}" bindtap="expandSectionChapter" data-part-id="{{item.id}}" data-chapter-id="{{chapter.id}}">
<view class="latest-expand-triangle {{_triangleAnimating === chapter.id ? 'tri-bounce' : ''}}"></view>
</view>
</view>
</block>
</view>

View File

@@ -577,6 +577,49 @@
color: rgba(255, 255, 255, 0.3);
}
/* ===== 展开三角 ===== */
.section-expand-trigger {
display: flex;
align-items: center;
justify-content: center;
padding: 20rpx 0 12rpx;
}
.latest-expand-triangle {
width: 0;
height: 0;
border-left: 18rpx solid transparent;
border-right: 18rpx solid transparent;
border-top: 14rpx solid rgba(0, 206, 209, 0.55);
opacity: 0.85;
transform-origin: 50% 0;
transition: border-top-color 0.15s ease;
}
.section-expand-trigger:active .latest-expand-triangle {
border-top-color: #00CED1;
}
@keyframes catalog-tri-nudge {
0% {
transform: translateY(0) scale(1);
opacity: 0.85;
}
40% {
transform: translateY(10rpx) scale(1.12);
opacity: 1;
border-top-color: #00CED1;
}
100% {
transform: translateY(0) scale(1);
opacity: 0.85;
}
}
.latest-expand-triangle.tri-bounce {
animation: catalog-tri-nudge 0.45s ease-out;
}
/* ===== 底部留白 ===== */
.bottom-space {
height: 40rpx;

View File

@@ -1,6 +1,7 @@
/**
* 卡若创业派对 - 开发登录页
* 临时:账户=手机号,密码可空,用于切换为对方账号调试
* 卡若创业派对 - 开发登录页(仅本地调试)
* 勿写入 app.json pages提审包不得注册本页。
* 需要用时在 app.json 的 pages 数组末尾临时加入 "pages/dev-login/dev-login"。
*/
const app = getApp()
const { checkAndExecute } = require('../../utils/ruleEngine.js')

View File

@@ -4,10 +4,23 @@
* 技术支持: 存客宝
*/
console.log('[Index] ===== 首页文件开始加载 =====')
const app = getApp()
const { trackClick } = require('../../utils/trackClick')
const { cleanSingleLineField } = require('../../utils/contentParser')
/** 与首页固定「卡若」获客位重复时从横滑列表剔除(含历史误写「卡路」) */
function isKaruoHostDuplicateName(displayName) {
const s = String(displayName || '').trim()
return s === '卡若' || s === '卡路'
}
/** 超级个体无头像占位:仅展示中文首字,避免头像圆里出现英文字母 */
function superAvatarLetter(displayName) {
const s = String(displayName || '').trim()
if (!s) return '会'
const ch = s[0]
return /[\u4e00-\u9fff]/.test(ch) ? ch : '会'
}
Page({
data: {
@@ -31,8 +44,8 @@ Page({
{ id: '8.1', title: '流量杠杆:抖音、Soul、飞书', tag: '推荐', tagClass: 'tag-purple', part: '真实的赚钱' }
],
// 最新章节(动态计算
latestSection: null,
// Banner 推荐(优先用 recommended API 第一条,回退 latest-chapters
bannerSection: null,
latestLabel: '最新更新',
// 内容概览
@@ -66,8 +79,7 @@ Page({
// 展开状态(首页精选/最新)
featuredExpanded: false,
latestExpanded: false,
featuredSectionsFull: [], // 展开时用 book/hot 加载的完整列表
featuredExpandedLoading: false,
featuredSectionsFull: [], // 精选排行榜全量(最多 50默认只展示前 3 条
// 功能配置(搜索开关)
searchEnabled: true,
@@ -136,28 +148,22 @@ Page({
async loadSuperMembers() {
this.setData({ superMembersLoading: true })
try {
// 并行请求 VIP 会员和普通用户,合并后取前 4 个VIP 优先)
const [vipRes, usersRes] = await Promise.all([
app.request({ url: '/api/miniprogram/vip/members', silent: true }).catch(() => null),
app.request({ url: '/api/miniprogram/users?limit=20', silent: true }).catch(() => null)
])
// 仅走后端 VIP 列表排序vip_sort、vip_activated_at不在端上拼普通用户
const vipRes = await app.request({ url: '/api/miniprogram/vip/members?limit=24', silent: true }).catch(() => null)
let members = []
if (vipRes && vipRes.success && Array.isArray(vipRes.data) && vipRes.data.length > 0) {
members = vipRes.data.slice(0, 4).map(u => ({
id: u.id,
name: u.nickname || u.vipName || u.vip_name || '会员',
avatar: u.avatar || '',
isVip: true
}))
if (members.length > 0) console.log('[Index] 超级个体加载成功:', members.length, '')
}
if (members.length < 4 && usersRes && usersRes.success && Array.isArray(usersRes.data)) {
const existIds = new Set(members.map(m => m.id))
const extra = usersRes.data
.filter(u => u.avatar && u.nickname && !existIds.has(u.id))
.slice(0, 4 - members.length)
.map(u => ({ id: u.id, name: u.nickname, avatar: u.avatar, isVip: u.is_vip === 1 }))
members = members.concat(extra)
members = vipRes.data.map(u => {
const raw = u.name || u.nickname || u.vipName || u.vip_name || '会员'
const name = cleanSingleLineField(raw) || '会员'
return {
id: u.id,
name,
avatar: u.avatar || '',
isVip: true,
avatarLetter: superAvatarLetter(name)
}
}).filter((m) => !isKaruoHostDuplicateName(m.name))
console.log('[Index] 超级个体(后端排序):', members.length, '人')
}
this.setData({ superMembers: members, superMembersLoading: false })
} catch (e) {
@@ -166,48 +172,79 @@ Page({
}
},
// 精选推荐 + 最新更新 + 最新列表:一次请求 recommended + latest-chapters避免重复
// 精选推荐 + 最新更新 + 最新列表:顺序以后端为准(recommended=排行榜算法latest=updated_at
async loadFeaturedAndLatest() {
try {
const excludeFixed = (c) => {
const pt = (c.part_title || c.partTitle || '').toLowerCase()
return !pt.includes('序言') && !pt.includes('尾声') && !pt.includes('附录')
const tagClassForTag = (tag) => (tag === '热门' ? 'tag-hot' : 'tag-rec')
const toSectionFromRanking = (s) => {
const tag = s.tag || '精选'
return {
id: s.id || s.section_id,
mid: s.mid ?? s.MID ?? 0,
title: s.section_title || s.sectionTitle || s.title || s.chapterTitle || '',
part: (s.part_title || s.partTitle || '').replace(/[_|]/g, ' ').trim(),
tag,
tagClass: tagClassForTag(tag)
}
}
const fallbackTags = ['热门', '推荐', '精选']
const toSectionFromHot = (s, i) => {
const tag = fallbackTags[i % 3]
return {
id: s.id || s.section_id,
mid: s.mid ?? s.MID ?? 0,
title: s.section_title || s.sectionTitle || s.title || s.chapterTitle || '',
part: (s.part_title || s.partTitle || '').replace(/[_|]/g, ' ').trim(),
tag,
tagClass: tagClassForTag(tag)
}
}
const toSection = (s, i, tagMap = ['热门', '推荐', '精选']) => ({
id: s.id || s.section_id,
mid: s.mid ?? s.MID ?? 0,
title: s.section_title || s.sectionTitle || s.title || s.chapterTitle || '',
part: (s.part_title || s.partTitle || '').replace(/[_|]/g, ' ').trim(),
tag: s.tag || tagMap[i] || '精选',
tagClass: ['tag-hot', 'tag-rec', 'tag-rec'][i] || 'tag-rec'
})
const [recRes, latestRes] = await Promise.all([
app.request({ url: '/api/miniprogram/book/recommended', silent: true }).catch(() => null),
app.request({ url: '/api/miniprogram/book/recommended?limit=50', silent: true }).catch(() => null),
app.request({ url: '/api/miniprogram/book/latest-chapters', silent: true }).catch(() => null)
])
// 1. 精选推荐recommended → hot 兜底
let featured = []
// 1. 精选推荐一次拉全量≤50默认只显示 3 条;点列表下三角展开(与「最新新增」一致
let featuredFull = []
if (recRes && recRes.success && Array.isArray(recRes.data) && recRes.data.length > 0) {
featured = recRes.data.map((s, i) => toSection(s, i))
featuredFull = recRes.data.map((s) => toSectionFromRanking(s))
}
if (featured.length === 0) {
if (featuredFull.length === 0) {
try {
const hotRes = await app.request({ url: '/api/miniprogram/book/hot?limit=10', silent: true })
const hotRes = await app.request({ url: '/api/miniprogram/book/hot?limit=50', silent: true })
const hotList = (hotRes && hotRes.data) ? hotRes.data : []
if (hotList.length > 0) featured = hotList.slice(0, 3).map((s, i) => toSection(s, i))
if (hotList.length > 0) featuredFull = hotList.map((s, i) => toSectionFromHot(s, i))
} catch (e) { console.log('[Index] book/hot 兜底失败:', e) }
}
if (featured.length > 0) this.setData({ featuredSections: featured })
if (featuredFull.length > 0) {
this.setData({
featuredSectionsFull: featuredFull,
featuredSections: featuredFull.slice(0, 3),
featuredExpanded: false
})
} else {
this.setData({
featuredSectionsFull: [],
featuredSections: [],
featuredExpanded: false
})
}
// 2. 最新更新 + 最新列表(共用 latest-chapters 数据)
// 2. Banner 推荐:优先取 recommended 第一条,回退 latest 第一条
const rawList = (latestRes && latestRes.data) ? latestRes.data : []
const latestList = rawList.filter(excludeFixed)
if (latestList.length > 0) {
// 按更新时间倒序,最新在前(与后台展示一致)
const latestList = [...rawList].sort((a, b) => {
const ta = new Date(a.updatedAt || a.updated_at || 0).getTime()
const tb = new Date(b.updatedAt || b.updated_at || 0).getTime()
return tb - ta
})
if (featuredFull.length > 0) {
this.setData({ bannerSection: featuredFull[0] })
} else if (latestList.length > 0) {
const l = latestList[0]
this.setData({
latestSection: {
bannerSection: {
id: l.id,
mid: l.mid ?? l.MID ?? 0,
title: l.section_title || l.sectionTitle || l.title || l.chapterTitle || '',
@@ -334,12 +371,6 @@ Page({
return
}
const userId = app.globalData.userInfo.id
// 2 分钟内只能点一次(与后端限频一致)
const leadLastTs = wx.getStorageSync('lead_last_submit_ts') || 0
if (Date.now() - leadLastTs < 2 * 60 * 1000) {
wx.showToast({ title: '操作太频繁请2分钟后再试', icon: 'none' })
return
}
let phone = (app.globalData.userInfo.phone || '').trim()
let wechatId = (app.globalData.userInfo.wechatId || app.globalData.userInfo.wechat_id || '').trim()
if (!phone && !wechatId) {
@@ -439,11 +470,6 @@ Page({
wx.showToast({ title: '请输入正确的手机号', icon: 'none' })
return
}
const leadLastTs = wx.getStorageSync('lead_last_submit_ts') || 0
if (Date.now() - leadLastTs < 2 * 60 * 1000) {
wx.showToast({ title: '操作太频繁请2分钟后再试', icon: 'none' })
return
}
const app = getApp()
const userId = app.globalData.userInfo?.id
wx.showLoading({ title: '提交中...', mask: true })
@@ -501,50 +527,24 @@ Page({
wx.switchTab({ url: '/pages/match/match' })
},
// 精选推荐:展开/折叠
async toggleFeaturedExpanded() {
if (this.data.featuredExpandedLoading) return
trackClick('home', 'tab_click', this.data.featuredExpanded ? '精选收起' : '精选展开')
if (this.data.featuredExpanded) {
const collapsed = this.data.featuredSectionsFull.length > 0 ? this.data.featuredSectionsFull.slice(0, 3) : this.data.featuredSections
this.setData({ featuredExpanded: false, featuredSections: collapsed })
return
}
if (this.data.featuredSectionsFull.length > 0) {
this.setData({ featuredExpanded: true, featuredSections: this.data.featuredSectionsFull })
return
}
this.setData({ featuredExpandedLoading: true })
try {
const res = await app.request({ url: '/api/miniprogram/book/hot?limit=50', silent: true })
const list = (res && res.data) ? res.data : []
const tagMap = ['热门', '推荐', '精选']
const full = list.map((s, i) => ({
id: s.id || s.section_id,
mid: s.mid ?? s.MID ?? 0,
title: s.sectionTitle || s.section_title || s.title || s.chapterTitle || '',
part: (s.partTitle || s.part_title || '').replace(/[_|]/g, ' ').trim(),
tag: tagMap[i % 3] || '精选',
tagClass: ['tag-hot', 'tag-rec', 'tag-rec'][i % 3] || 'tag-rec'
}))
this.setData({
featuredSectionsFull: full,
featuredSections: full,
featuredExpanded: true,
featuredExpandedLoading: false
})
} catch (e) {
console.log('[Index] 加载精选更多失败:', e)
this.setData({ featuredExpandedLoading: false })
}
// 精选推荐:列表下方小三角展开(数据已在 loadFeaturedAndLatest 一次拉齐)
expandFeaturedChapters() {
if (this.data.featuredExpanded) return
const full = this.data.featuredSectionsFull || []
if (full.length <= 3) return
trackClick('home', 'tab_click', '精选展开_底部三角')
this.setData({ featuredExpanded: true, featuredSections: full })
},
// 最新新增:展开/折叠(默认 5 条,点击展开剩余
toggleLatestExpanded() {
trackClick('home', 'tab_click', this.data.latestExpanded ? '最新收起' : '最新展开')
const expanded = !this.data.latestExpanded
const display = expanded ? this.data.latestChapters : this.data.latestChapters.slice(0, 5)
this.setData({ latestExpanded: expanded, displayLatestChapters: display })
// 最新新增:列表下方小三角展开(无「收起」,展开后整页向下滚动查看
expandLatestChapters() {
if (this.data.latestExpanded) return
trackClick('home', 'tab_click', '最新展开_底部三角')
const full = this.data.latestChapters || []
this.setData({
latestExpanded: true,
displayLatestChapters: full
})
},
goToMemberDetail(e) {

View File

@@ -4,12 +4,12 @@
<!-- 自定义导航栏占位 -->
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
<!-- 顶部区域按设计稿S 图标 + 标题副标题 | 点击链接卡若 -->
<!-- 顶部区域:中文标识 + 标题副标题 | 链接卡若 -->
<view class="header">
<view class="header-content">
<view class="logo-section">
<view class="logo-icon">
<text class="logo-text">S</text>
<text class="logo-text"></text>
</view>
<view class="logo-info">
<text class="logo-title-text">卡若创业派对</text>
@@ -19,7 +19,7 @@
<view class="header-right">
<view class="contact-btn" bindtap="onLinkKaruo">
<image class="contact-avatar" src="/assets/images/author-avatar.png" mode="aspectFill"/>
<text class="contact-text">点击链接卡若</text>
<text class="contact-name">点击链接卡若</text>
</view>
</view>
</view>
@@ -35,13 +35,13 @@
<!-- 主内容区 -->
<view class="main-content">
<!-- Banner卡片 - 最新章节(异步加载 -->
<view class="banner-card" wx:if="{{latestSection}}" bindtap="goToRead" data-id="{{latestSection.id}}" data-mid="{{latestSection.mid}}">
<!-- Banner 推荐卡片(优先 recommended API 第一条 -->
<view class="banner-card" wx:if="{{bannerSection}}" bindtap="goToRead" data-id="{{bannerSection.id}}" data-mid="{{bannerSection.mid}}">
<view class="banner-glow"></view>
<view class="banner-tag">推荐</view>
<view class="banner-title">{{latestSection.title}}</view>
<view class="banner-title">{{bannerSection.title}}</view>
<view class="banner-action">
<text class="banner-action-text">点击阅读123</text>
<text class="banner-action-text">点击阅读</text>
<icon name="direction-right" size="32" color="#00CED1" customClass="banner-arrow"></icon>
</view>
</view>
@@ -53,10 +53,11 @@
</view>
<!-- 超级个体(横向滚动,已去掉「查看全部」;审核模式隐藏 -->
<!-- 超级个体:与匹配页一致,仅 VIP 横向列表(无首位特例 -->
<view class="section" wx:if="{{!auditMode}}">
<view class="section-header">
<text class="section-title">超级个体</text>
<text class="section-subtitle">获客入口</text>
</view>
<!-- 加载中:骨架动画 -->
<view wx:if="{{superMembersLoading}}" class="super-loading">
@@ -76,12 +77,16 @@
wx:key="id"
bindtap="goToMemberDetail"
data-id="{{item.id}}"
hover-class="super-item-hover"
hover-stay-time="80"
>
<view class="super-avatar {{item.isVip ? 'super-avatar-vip' : ''}}">
<image class="super-avatar-img" wx:if="{{item.avatar}}" src="{{item.avatar}}" mode="aspectFill"/>
<text class="super-avatar-text" wx:else>{{(item.name && item.name[0]) || '会'}}</text>
<view class="super-item-stack">
<view class="super-avatar {{item.isVip ? 'super-avatar-vip' : ''}}">
<image class="super-avatar-img" wx:if="{{item.avatar}}" src="{{item.avatar}}" mode="aspectFill"/>
<text class="super-avatar-text" wx:else>{{item.avatarLetter}}</text>
</view>
<text class="super-name">{{item.name}}</text>
</view>
<text class="super-name">{{item.name}}</text>
</view>
</view>
</scroll-view>
@@ -92,14 +97,10 @@
</view>
</view>
<!-- 精选推荐(带 tag支持展开更多 -->
<!-- 精选推荐:默认 3 条,列表下三角展开更多(与最新新增一致 -->
<view class="section">
<view class="section-header">
<text class="section-title">精选推荐</text>
<view class="section-more" wx:if="{{featuredSections.length > 0}}" bindtap="toggleFeaturedExpanded">
<text class="more-text">{{featuredExpandedLoading ? '加载中...' : (featuredExpanded ? '收起' : '展开更多')}}</text>
<icon name="{{featuredExpanded ? 'chevron-up' : 'chevron-down'}}" size="28" color="rgba(255,255,255,0.6)" customClass="more-arrow"></icon>
</view>
</view>
<view class="featured-list">
<view
@@ -111,30 +112,29 @@
data-mid="{{item.mid}}"
>
<view class="featured-content">
<view class="featured-meta">
<text class="featured-id brand-color">{{item.id}}</text>
<text class="featured-tag {{item.tagClass || 'tag-rec'}}" wx:if="{{item.tag}}">{{item.tag}}</text>
<view class="featured-meta" wx:if="{{item.tag}}">
<text class="featured-tag {{item.tagClass || 'tag-rec'}}">{{item.tag}}</text>
</view>
<text class="featured-title">{{item.title}}</text>
</view>
<icon name="chevron-right" size="28" color="rgba(255,255,255,0.6)" customClass="featured-arrow"></icon>
</view>
</view>
<view
class="latest-expand-hint"
wx:if="{{!featuredExpanded && featuredSectionsFull.length > 3}}"
bindtap="expandFeaturedChapters"
hover-class="latest-expand-hint-hover"
hover-stay-time="80"
>
<view class="latest-expand-triangle"></view>
</view>
</view>
<!-- 最新新增(时间线样式,支持展开更多 -->
<!-- 最新新增(时间线样式;超过 5 条时在列表下方点小三角展开,展开后随页面滚动看全部 -->
<view class="section" wx:if="{{latestChapters.length > 0}}">
<view class="section-header latest-header">
<text class="section-title">最新新增</text>
<view class="section-header-right">
<view class="daily-badge-wrap">
<text class="daily-badge">+{{latestChapters.length}}</text>
</view>
<view class="section-more" wx:if="{{latestChapters.length > 5}}" bindtap="toggleLatestExpanded">
<text class="more-text">{{latestExpanded ? '收起' : '展开更多'}}</text>
<icon name="{{latestExpanded ? 'chevron-up' : 'chevron-down'}}" size="28" color="rgba(255,255,255,0.6)" customClass="more-arrow"></icon>
</view>
</view>
</view>
<view class="timeline-wrap">
<view class="timeline-line"></view>
@@ -149,6 +149,16 @@
</view>
</view>
</view>
<!-- 仅文案「展开更多」去掉:下方居中轻点小三角,点一次展开剩余条目 -->
<view
class="latest-expand-hint"
wx:if="{{latestChapters.length > 5 && !latestExpanded}}"
bindtap="expandLatestChapters"
hover-class="latest-expand-hint-hover"
hover-stay-time="80"
>
<view class="latest-expand-triangle"></view>
</view>
</view>
</view>

View File

@@ -63,26 +63,28 @@
.contact-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
padding: 8rpx 20rpx 8rpx 12rpx;
background: rgba(255, 255, 255, 0.08);
border: 2rpx solid rgba(255, 255, 255, 0.1);
border-radius: 40rpx;
font-size: 24rpx;
font-weight: 500;
gap: 8rpx;
padding: 12rpx 16rpx 10rpx;
background: rgba(255, 255, 255, 0.06);
border: 1rpx solid rgba(255, 255, 255, 0.1);
border-radius: 20rpx;
color: #ffffff;
}
.contact-avatar {
width: 48rpx;
height: 48rpx;
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
border: 2rpx solid rgba(0, 206, 209, 0.35);
}
.contact-text {
font-size: 24rpx;
.contact-name {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.7);
white-space: nowrap;
}
.logo-title {
@@ -330,6 +332,12 @@
color: #ffffff;
}
.section-subtitle {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.42);
font-weight: 400;
}
.section-more {
display: flex;
align-items: center;
@@ -609,9 +617,18 @@
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
min-width: 140rpx;
}
.super-item-hover {
opacity: 0.88;
}
.super-item-stack {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
width: 100%;
}
.super-scroll .super-avatar {
width: 112rpx;
@@ -692,6 +709,7 @@
.daily-badge-wrap {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
/* 设计稿 1:1橙底白字 rounded-full */
.daily-badge {
@@ -701,10 +719,29 @@
font-weight: 700;
padding: 8rpx 20rpx;
border-radius: 999rpx;
margin-left: 8rpx;
box-shadow: 0 4rpx 16rpx rgba(246, 173, 85, 0.3);
}
/* 列表下方:仅小三角,点击展开(替代标题栏「展开更多」) */
.latest-expand-hint {
display: flex;
justify-content: center;
align-items: center;
padding: 8rpx 0 16rpx;
margin-top: 8rpx;
}
.latest-expand-hint-hover {
opacity: 0.65;
}
/* 向下小三角 */
.latest-expand-triangle {
width: 0;
height: 0;
border-left: 16rpx solid transparent;
border-right: 16rpx solid transparent;
border-top: 20rpx solid rgba(0, 206, 209, 0.85);
}
/* 设计稿 1:1pl-3 竖线 left-3 top-2 bottom-2 w-[1px] bg-gray-800 */
.timeline-wrap {
position: relative;

View File

@@ -226,6 +226,17 @@ Page({
if (!userId) { callback(); return }
try {
const res = await app.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true })
const avatar = res?.data?.avatarUrl || app.globalData.userInfo?.avatarUrl || ''
const isDefaultAvatar = !avatar || avatar.includes('default') || avatar.includes('132')
if (isDefaultAvatar) {
wx.showModal({
title: '完善头像',
content: '请先设置头像后再使用匹配功能',
confirmText: '去设置',
success: (r) => { if (r.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) }
})
return
}
const phone = (res?.data?.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
const wechat = (res?.data?.wechatId || res?.data?.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
if (phone && /^1[3-9]\d{9}$/.test(phone)) {
@@ -311,7 +322,7 @@ Page({
confirmText: '去购买',
success: (res) => {
if (res.confirm) {
wx.switchTab({ url: '/pages/catalog/catalog' })
wx.switchTab({ url: '/pages/chapters/chapters' })
}
}
})
@@ -468,34 +479,6 @@ Page({
}, delay)
},
// 生成模拟匹配数据
generateMockMatch() {
const nicknames = ['创业先锋', '资源整合者', '私域专家', '导师顾问', '连续创业者']
const concepts = [
'专注私域流量运营5年帮助100+品牌实现从0到1的增长。',
'连续创业者,擅长商业模式设计和资源整合。',
'在Soul分享真实创业故事希望找到志同道合的合作伙伴。'
]
const wechats = ['soul_partner_1', 'soul_business_2024', 'soul_startup_fan']
const index = Math.floor(Math.random() * nicknames.length)
const currentType = MATCH_TYPES.find(t => t.id === this.data.selectedType)
return {
id: `user_${Date.now()}`,
nickname: nicknames[index],
avatar: `https://picsum.photos/200/200?random=${Date.now()}`,
tags: ['创业者', '私域运营', currentType?.label || '创业合伙'],
matchScore: Math.floor(Math.random() * 20) + 80,
concept: concepts[index % concepts.length],
wechat: wechats[index % wechats.length],
commonInterests: [
{ icon: 'book-open', text: '都在读《创业派对》' },
{ icon: 'briefcase', text: '对私域运营感兴趣' },
{ icon: 'target', text: '相似的创业方向' }
]
}
},
// 上报匹配行为
async reportMatch(matchedUser) {
@@ -639,18 +622,10 @@ Page({
this.setData({ showJoinModal: false, joinSuccess: false })
}, 2000)
} else {
// 即使API返回失败也模拟成功因为已保存本地
this.setData({ joinSuccess: true })
setTimeout(() => {
this.setData({ showJoinModal: false, joinSuccess: false })
}, 2000)
wx.showToast({ title: res.error || '加入失败', icon: 'none' })
}
} catch (e) {
// 网络错误时也模拟成功
this.setData({ joinSuccess: true })
setTimeout(() => {
this.setData({ showJoinModal: false, joinSuccess: false })
}, 2000)
wx.showToast({ title: '网络异常,请重试', icon: 'none' })
} finally {
this.setData({ isJoining: false })
}
@@ -727,19 +702,7 @@ Page({
if (e.errMsg && e.errMsg.includes('cancel')) {
wx.showToast({ title: '已取消', icon: 'none' })
} else {
// 测试模式
wx.showModal({
title: '支付服务暂不可用',
content: '是否使用测试模式购买?',
success: (res) => {
if (res.confirm) {
const extraMatches = (wx.getStorageSync('extra_match_count') || 0) + 1
wx.setStorageSync('extra_match_count', extraMatches)
wx.showToast({ title: '测试购买成功', icon: 'success' })
this.initUserStatus()
}
}
})
wx.showToast({ title: '支付失败,请重试', icon: 'none' })
}
}
},
@@ -750,10 +713,6 @@ Page({
wx.switchTab({ url: '/pages/chapters/chapters' })
},
// 打开资料修改页(找伙伴右上角图标)
openSettings() {
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
},
// 阻止事件冒泡
preventBubble() {},

View File

@@ -12,7 +12,7 @@
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
<!-- 顶部留白,让内容往下 -->
<view style="height: 30rpx;"></view>
<view style="height: 16rpx;"></view>
<!-- 匹配提示条 - 简化显示 -->
<view class="match-tip-bar" wx:if="{{matchesRemaining <= 0 && !hasFullBook}}">

View File

@@ -5,15 +5,17 @@
* mbti, region, industry, position, businessScale, skills,
* storyBestMonth→bestMonth, storyAchievement→achievement, storyTurning→turningPoint,
* helpOffer→canHelp, helpNeed→needHelp
* 点头像:若后台 persons.user_id 已绑定则带 ckbLeadToken走存客宝 CKBLead与阅读页 @ 一致)
*/
const app = getApp()
Page({
data: { statusBarHeight: 44, member: null, loading: true },
data: { statusBarHeight: 44, navBarTotalPx: 88, member: null, loading: true },
onLoad(options) {
wx.showShareMenu({ withShareTimeline: true })
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
const sb = app.globalData.statusBarHeight || 44
this.setData({ statusBarHeight: sb, navBarTotalPx: sb + 44 })
if (options.id) this.loadMember(options.id)
},
@@ -44,6 +46,7 @@ Page({
storyTurning: u.storyTurning || u.story_turning,
helpOffer: u.helpOffer || u.help_offer,
helpNeed: u.helpNeed || u.help_need,
ckbLeadToken: u.ckbLeadToken || u.ckb_lead_token,
}), loading: false })
return
}
@@ -79,7 +82,8 @@ Page({
turningPoint: e(raw.turningPoint || raw.storyTurning || raw.story_turning),
canHelp: e(raw.canHelp || raw.helpOffer || raw.help_offer),
needHelp: e(raw.needHelp || raw.helpNeed || raw.help_need),
project: e(raw.project || raw.vipProject || raw.vip_project || raw.projectIntro || raw.project_intro)
project: e(raw.project || raw.vipProject || raw.vip_project || raw.projectIntro || raw.project_intro),
ckbLeadToken: String(raw.ckbLeadToken || raw.ckb_lead_token || '').trim()
}
const contact = merged.contactRaw || ''
@@ -130,6 +134,189 @@ Page({
return d.contact || d.wechat
},
/** VIP 或本会员首次免费:写入解锁;否则弹开通 VIP */
_tryFreeUnlock(member, field) {
const isVip = app.globalData.isVip
const usedFree = this._hasUsedFreeForMember(member.id)
if (isVip || !usedFree) {
this._addUnlock(member.id, field)
return true
}
wx.showModal({
title: '解锁' + (field === 'contact' ? '联系方式' : '微信号'),
content: '您的免费解锁次数已用完开通VIP会员¥1980/年)可无限解锁',
confirmText: '去开通',
cancelText: '取消',
success: (res) => { if (res.confirm) wx.navigateTo({ url: '/pages/vip/vip' }) }
})
return false
},
/**
* 点头像:有存客宝人物 token 时优先 POST /api/miniprogram/ckb/lead与阅读页 @ 同链路,匹配 persons.ckb_api_key 计划)
* 否则:解锁后复制微信/手机号并引导
*/
startLinkFlow() {
const member = this.data.member
if (!member) return
const leadTok = (member.ckbLeadToken || '').trim()
if (leadTok) {
const nickname = ((member.name || 'TA').trim() || 'TA')
wx.showModal({
title: '添加好友',
content: `是否通过获客计划联系 ${nickname}?提交后将按对方在存客宝后台配置的计划执行。`,
confirmText: '确定',
cancelText: '取消',
success: (res) => {
if (res.confirm) this._doCkbLeadSubmit(leadTok, nickname)
}
})
return
}
if (member.wechatRaw || member.wechatDisplay) {
if (!this._ensureUnlockedForLink('wechat')) return
const m = this.data.member
if (m.wechatFull) this._copyAndGuideWechat(m.wechatFull)
return
}
if (member.contactRaw || member.contactDisplay) {
if (!this._ensureUnlockedForLink('contact')) return
const m = this.data.member
if (m.contactFull) this._copyAndGuidePhone(m.contactFull)
return
}
wx.showToast({ title: '暂未公开联系方式', icon: 'none' })
},
/** 与 read 页 _doMentionAddFriend 一致targetUserId = Person.token */
async _doCkbLeadSubmit(targetUserId, targetNickname) {
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
wx.showModal({
title: '提示',
content: '请先登录后再添加好友',
confirmText: '去登录',
cancelText: '取消',
success: (res) => { if (res.confirm) wx.switchTab({ url: '/pages/my/my' }) }
})
return
}
const myUserId = app.globalData.userInfo.id
let phone = (app.globalData.userInfo.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
let wechatId = (app.globalData.userInfo.wechatId || app.globalData.userInfo.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
try {
const profileRes = await app.request({ url: `/api/miniprogram/user/profile?userId=${myUserId}`, silent: true })
if (profileRes?.success && profileRes.data) {
phone = (profileRes.data.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
wechatId = (profileRes.data.wechatId || profileRes.data.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
}
} catch (e) {}
}
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
wx.showModal({
title: '完善资料',
content: '请先填写手机号(必填),以便对方通过获客计划联系您',
confirmText: '去填写',
cancelText: '取消',
success: (res) => { if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) }
})
return
}
wx.showLoading({ title: '提交中...', mask: true })
try {
const res = await app.request({
url: '/api/miniprogram/ckb/lead',
method: 'POST',
data: {
userId: myUserId,
phone: phone || undefined,
wechatId: wechatId || undefined,
name: (app.globalData.userInfo.nickname || '').trim() || undefined,
targetUserId,
targetNickname: targetNickname || undefined,
source: 'member_detail_avatar'
}
})
wx.hideLoading()
if (res && res.success) {
wx.setStorageSync('lead_last_submit_ts', Date.now())
wx.showToast({ title: res.message || '提交成功,对方会尽快联系您', icon: 'success' })
} else {
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: (e && e.message) || '提交失败', icon: 'none' })
}
},
_ensureUnlockedForLink(field) {
const member = this.data.member
if (!member?.id || (field !== 'contact' && field !== 'wechat')) return false
if (field === 'wechat' && member.wechatUnlocked) return true
if (field === 'contact' && member.contactUnlocked) return true
if (!app.globalData.isLoggedIn) {
wx.showModal({
title: '需要登录',
content: field === 'wechat'
? '登录后可解锁并复制对方微信号,再按步骤去微信添加好友。'
: '登录后可解锁并复制对方手机号,便于添加好友或回拨。',
confirmText: '去登录',
cancelText: '取消',
success: (res) => { if (res.confirm) wx.switchTab({ url: '/pages/my/my' }) }
})
return false
}
const d = this._getUnlockData(member.id)
if ((field === 'wechat' && d.wechat) || (field === 'contact' && d.contact)) {
this.setData({ member: this.enrichAndFormat(member) })
return true
}
if (!this._tryFreeUnlock(member, field)) return false
this.setData({ member: this.enrichAndFormat(member) })
return true
},
_copyAndGuideWechat(wechatId) {
if (!wechatId) return
wx.setClipboardData({
data: String(wechatId),
success: () => {
wx.hideToast()
setTimeout(() => {
wx.hideToast()
wx.showModal({
title: '添加微信好友',
content: '微信号已复制。\n\n请打开微信 → 右上角「+」→ 添加朋友 → 粘贴搜索并添加。',
showCancel: false,
confirmText: '知道了'
})
}, 120)
},
fail: () => wx.showToast({ title: '复制失败', icon: 'none' })
})
},
_copyAndGuidePhone(phone) {
if (!phone) return
wx.setClipboardData({
data: String(phone),
success: () => {
wx.hideToast()
setTimeout(() => {
wx.hideToast()
wx.showModal({
title: '联系对方',
content: '手机号已复制。\n\n可打开微信「添加朋友」搜索手机号或使用手机拨号联系对方。',
showCancel: false,
confirmText: '知道了'
})
}, 120)
},
fail: () => wx.showToast({ title: '复制失败', icon: 'none' })
})
},
unlockField(e) {
const field = e.currentTarget.dataset.field
if (!field) return
@@ -148,22 +335,25 @@ Page({
}
const d = this._getUnlockData(member.id)
if (d[field]) return
const isVip = app.globalData.isVip
const usedFree = this._hasUsedFreeForMember(member.id)
if (isVip || !usedFree) {
this._addUnlock(member.id, field)
if (this._tryFreeUnlock(member, field)) {
const m = this.enrichAndFormat(member)
this.setData({ member: m })
wx.showToast({ title: field === 'contact' ? '已解锁联系方式' : '已解锁微信号', icon: 'success' })
return
}
wx.showModal({
title: '解锁' + (field === 'contact' ? '联系方式' : '微信号'),
content: '您的免费解锁次数已用完开通VIP会员¥1980/年)可无限解锁',
confirmText: '去开通',
cancelText: '取消',
success: (res) => { if (res.confirm) wx.navigateTo({ url: '/pages/vip/vip' }) }
})
},
tapContactRow() {
const m = this.data.member
if (!m || !(m.contactRaw || m.contactDisplay)) return
if (m.contactUnlocked) this.copyContact()
else this.unlockField({ currentTarget: { dataset: { field: 'contact' } } })
},
tapWechatRow() {
const m = this.data.member
if (!m || !(m.wechatRaw || m.wechatDisplay)) return
if (m.wechatUnlocked) this.copyWechat()
else this.unlockField({ currentTarget: { dataset: { field: 'wechat' } } })
},
copyContact() {

View File

@@ -1,6 +1,5 @@
<!-- 卡若创业派对 - 超级个体详情(按 enhanced_professional_profile 1:1 还原 -->
<!-- 卡若创业派对 - 超级个体详情(居中头像区 + 低调联系方式 + 信息卡 -->
<view class="page">
<!-- 导航栏 -->
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-back" bindtap="goBack">
<icon name="chevron-left" size="44" color="#5EEAD4" customClass="nav-icon"></icon>
@@ -10,135 +9,176 @@
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<scroll-view scroll-y class="scroll-wrap" wx:if="{{member}}">
<!-- 顶部 profile 卡片 -->
<view class="card-profile">
<view class="profile-deco"></view>
<view class="profile-body">
<view class="avatar-outer">
<view class="avatar-wrap {{member.isVip ? 'vip-ring' : ''}}">
<image class="avatar-img" wx:if="{{member.avatar}}" src="{{member.avatar}}" mode="aspectFill"/>
<view class="avatar-ph" wx:else><text>{{(member.name && member.name[0]) || '创'}}</text></view>
<scroll-view scroll-y class="scroll-wrap" style="height: calc(100vh - {{navBarTotalPx}}px);" wx:if="{{member}}">
<!-- 首屏:居中头像 + 昵称 + 标签;点头像走添加微信引导(无独立「链接 TA」大按钮 -->
<view class="shell">
<view class="shell-glow"></view>
<view class="hero-profile">
<view class="hero-avatar-block" bindtap="startLinkFlow" hover-class="hero-avatar-block-hover" hover-stay-time="80">
<view class="avatar-outer">
<view class="avatar-wrap {{member.isVip ? 'vip-ring' : ''}}">
<image class="avatar-img" wx:if="{{member.avatar}}" src="{{member.avatar}}" mode="aspectFill"/>
<view class="avatar-ph" wx:else><text>{{(member.name && member.name[0]) || '创'}}</text></view>
</view>
<view class="vip-tag" wx:if="{{member.isVip}}">VIP</view>
</view>
<text class="profile-name">{{member.name}}</text>
<view class="profile-tags profile-tags-modern" wx:if="{{member.mbti || member.region}}">
<text class="tag tag-mbti" wx:if="{{member.mbti}}">{{member.mbti}}</text>
<view class="tag tag-region" wx:if="{{member.region}}">
<icon name="map-pin" size="22" color="currentColor" customClass="pin-icon"></icon>
<text>{{member.region}}</text>
</view>
</view>
<view class="vip-tag" wx:if="{{member.isVip}}">VIP</view>
</view>
<text class="profile-name">{{member.name}}</text>
<view class="profile-tags" wx:if="{{member.mbti || member.region}}">
<text class="tag tag-mbti" wx:if="{{member.mbti}}">{{member.mbti}}</text>
<view class="tag tag-region" wx:if="{{member.region}}"><icon name="map-pin" size="24" color="currentColor" customClass="pin-icon"></icon><text>{{member.region}}</text></view>
</view>
<view class="contact-rows contact-rows-subtle">
<view
class="link-chip link-chip-subtle {{member.contactUnlocked ? 'link-chip-open' : ''}}"
wx:if="{{member.contactRaw || member.contactDisplay}}"
catchtap="tapContactRow"
>
<view class="link-chip-icon link-chip-icon-phone link-chip-icon-subtle">
<icon name="smartphone" size="26" color="rgba(148,163,184,0.85)" customClass="lc-ic"></icon>
</view>
<view class="link-chip-main">
<text class="link-chip-label link-chip-label-subtle">手机</text>
<text class="link-chip-val mono link-chip-val-subtle">{{member.contactDisplay || member.contactRaw}}</text>
</view>
<view class="link-chip-action link-chip-action-subtle">
<text>{{member.contactUnlocked ? '复制' : '解锁'}}</text>
<icon wx:if="{{!member.contactUnlocked}}" name="chevron-right" size="22" color="rgba(100,116,139,0.8)" customClass="lc-arr"></icon>
</view>
</view>
<view
class="link-chip link-chip-subtle {{member.wechatUnlocked ? 'link-chip-open' : ''}}"
wx:if="{{member.wechatRaw || member.wechatDisplay}}"
catchtap="tapWechatRow"
>
<view class="link-chip-icon link-chip-icon-wx link-chip-icon-subtle">
<icon name="message-circle" size="26" color="rgba(148,163,184,0.85)" customClass="lc-ic"></icon>
</view>
<view class="link-chip-main">
<text class="link-chip-label link-chip-label-subtle">微信</text>
<text class="link-chip-val mono link-chip-val-subtle">{{member.wechatDisplay || member.wechatRaw}}</text>
</view>
<view class="link-chip-action link-chip-action-subtle">
<text>{{member.wechatUnlocked ? '复制' : '解锁'}}</text>
<icon wx:if="{{!member.wechatUnlocked}}" name="chevron-right" size="22" color="rgba(100,116,139,0.8)" customClass="lc-arr"></icon>
</view>
</view>
<view class="link-empty link-empty-subtle" wx:if="{{!(member.contactRaw || member.contactDisplay) && !(member.wechatRaw || member.wechatDisplay)}}">
<text class="link-empty-txt">暂未公开联系方式</text>
</view>
</view>
</view>
<!-- 基本信息(未填写行已隐藏 -->
<view class="card" wx:if="{{member.industry || member.position || member.businessScale || member.skills || member.contactRaw || member.contactDisplay || member.wechatRaw || member.wechatDisplay}}">
<view class="card-head">
<icon name="user" size="48" color="#00CED1" customClass="card-icon"></icon>
<text class="card-label">基本信息</text>
</view>
<view class="card-body">
<view class="field" wx:if="{{member.industry}}">
<text class="f-key">行业</text>
<text class="f-val">{{member.industry}}</text>
<!-- 一体化信息区(单卡片内分区 -->
<view class="mono-card mono-card-compact" wx:if="{{member.industry || member.position || member.businessScale || member.skills || member.bestMonth || member.achievement || member.turningPoint || member.canHelp || member.needHelp || member.project}}">
<!-- 职业画像 -->
<view class="mono-sec mono-sec-tight" wx:if="{{member.industry || member.position || member.businessScale}}">
<view class="mono-sec-head mono-sec-head-tight">
<text class="mono-sec-title">职业画像</text>
</view>
<view class="field" wx:if="{{member.position}}">
<text class="f-key">职位</text>
<text class="f-val">{{member.position}}</text>
</view>
<view class="field" wx:if="{{member.businessScale}}">
<text class="f-key">业务体量</text>
<text class="f-val">{{member.businessScale}}</text>
</view>
<view class="divider" wx:if="{{member.industry || member.position || member.businessScale}}"></view>
<view class="field" wx:if="{{member.skills}}">
<text class="f-key">我擅长</text>
<text class="f-val">{{member.skills}}</text>
</view>
<view class="field" wx:if="{{member.contactRaw || member.contactDisplay}}">
<text class="f-key">联系方式</text>
<view class="f-row">
<text class="f-val mono">{{member.contactUnlocked ? member.contactFull : (member.contactDisplay || member.contactRaw)}}</text>
<view class="icon-copy icon-eye-off" wx:if="{{member.contactRaw && !member.contactUnlocked}}" bindtap="unlockField" data-field="contact">
<image class="icon-img" src="/assets/icons/eye-off.svg" mode="aspectFit"/>
</view>
<view class="icon-copy" wx:elif="{{member.contactRaw && member.contactUnlocked}}" bindtap="copyContact"><icon name="clipboard" size="32" color="#00CED1"></icon></view>
<view class="kv-grid">
<view class="kv-cell" wx:if="{{member.industry}}">
<text class="kv-k">行业</text>
<text class="kv-v">{{member.industry}}</text>
</view>
</view>
<view class="field" wx:if="{{member.wechatRaw || member.wechatDisplay}}">
<text class="f-key">微信号</text>
<view class="f-row">
<text class="f-val mono">{{member.wechatUnlocked ? member.wechatFull : (member.wechatDisplay || member.wechatRaw)}}</text>
<view class="icon-copy icon-eye-off" wx:if="{{member.wechatRaw && !member.wechatUnlocked}}" bindtap="unlockField" data-field="wechat">
<image class="icon-img" src="/assets/icons/eye-off.svg" mode="aspectFit"/>
</view>
<view class="icon-copy" wx:elif="{{member.wechatRaw && member.wechatUnlocked}}" bindtap="copyWechat"><icon name="clipboard" size="32" color="#00CED1"></icon></view>
<view class="kv-cell" wx:if="{{member.position}}">
<text class="kv-k">职位</text>
<text class="kv-v">{{member.position}}</text>
</view>
<view class="kv-cell kv-cell-full" wx:if="{{member.businessScale}}">
<text class="kv-k">业务体量</text>
<text class="kv-v">{{member.businessScale}}</text>
</view>
</view>
</view>
</view>
<!-- 个人故事(未填写行已隐藏) -->
<view class="card" wx:if="{{member.bestMonth || member.achievement || member.turningPoint}}">
<view class="card-head">
<icon name="lightbulb" size="48" color="#FFD700" customClass="card-icon bulb"></icon>
<text class="card-label">个人故事</text>
<view class="mono-divider" wx:if="{{(member.industry || member.position || member.businessScale) && member.skills}}"></view>
<!-- 核心能力 -->
<view class="mono-sec mono-sec-tight skills-showcase" wx:if="{{member.skills}}">
<view class="mono-sec-head mono-sec-head-tight">
<text class="mono-sec-title">我擅长</text>
</view>
<view class="skills-quote">
<text class="skills-quote-text">{{member.skills}}</text>
</view>
</view>
<view class="card-body">
<view class="story" wx:if="{{member.bestMonth}}">
<view class="story-head"><icon name="trophy" size="28" color="#FFD700" customClass="story-icon"></icon><text class="story-q">最赚钱的一个月做的是什么</text></view>
<view class="mono-divider" wx:if="{{member.skills && (member.bestMonth || member.achievement || member.turningPoint)}}"></view>
<!-- 个人故事 -->
<view class="mono-sec mono-sec-tight" wx:if="{{member.bestMonth || member.achievement || member.turningPoint}}">
<view class="mono-sec-head mono-sec-head-tight">
<text class="mono-sec-title">个人故事</text>
</view>
<view class="story story-compact" wx:if="{{member.bestMonth}}">
<view class="story-head"><icon name="trophy" size="24" color="#FBBF24" customClass="story-icon"></icon><text class="story-q">最赚钱的一个月</text></view>
<text class="story-a">{{member.bestMonth}}</text>
</view>
<view class="divider" wx:if="{{member.bestMonth}}"></view>
<view class="story" wx:if="{{member.achievement}}">
<view class="story-head"><icon name="star" size="28" color="#FFD700" customClass="story-icon"></icon><text class="story-q">最有成就感的一件事</text></view>
<view class="story-gap story-gap-tight" wx:if="{{member.bestMonth && (member.achievement || member.turningPoint)}}"></view>
<view class="story story-compact" wx:if="{{member.achievement}}">
<view class="story-head"><icon name="star" size="24" color="#FBBF24" customClass="story-icon"></icon><text class="story-q">最有成就感的事</text></view>
<text class="story-a">{{member.achievement}}</text>
</view>
<view class="divider" wx:if="{{member.achievement}}"></view>
<view class="story" wx:if="{{member.turningPoint}}">
<view class="story-head"><icon name="refresh-cw" size="28" color="#FFD700" customClass="story-icon turn"></icon><text class="story-q">人生的转折点</text></view>
<view class="story-gap story-gap-tight" wx:if="{{member.achievement && member.turningPoint}}"></view>
<view class="story story-compact" wx:if="{{member.turningPoint}}">
<view class="story-head"><icon name="refresh-cw" size="24" color="#FBBF24" customClass="story-icon"></icon><text class="story-q">人生的转折点</text></view>
<text class="story-a">{{member.turningPoint}}</text>
</view>
</view>
<view class="mono-divider" wx:if="{{(member.bestMonth || member.achievement || member.turningPoint) && (member.canHelp || member.needHelp)}}"></view>
<!-- 互助 -->
<view class="mono-sec mono-sec-tight" wx:if="{{member.canHelp || member.needHelp}}">
<view class="mono-sec-head mono-sec-head-tight">
<text class="mono-sec-title">互助需求</text>
</view>
<view class="help-grid">
<view class="help-tile help-give" wx:if="{{member.canHelp}}">
<text class="help-tile-tag">我能帮你</text>
<text class="help-tile-txt">{{member.canHelp}}</text>
</view>
<view class="help-tile help-need" wx:if="{{member.needHelp}}">
<text class="help-tile-tag need">我需要</text>
<text class="help-tile-txt">{{member.needHelp}}</text>
</view>
</view>
</view>
<view class="mono-divider" wx:if="{{(member.canHelp || member.needHelp) && member.project}}"></view>
<view class="mono-sec mono-sec-tight" wx:if="{{member.project}}">
<view class="mono-sec-head mono-sec-head-tight">
<text class="mono-sec-title">项目介绍</text>
</view>
<text class="proj-body proj-body-compact">{{member.project}}</text>
</view>
</view>
<!-- 互助需求(未填写行已隐藏 -->
<view class="card" wx:if="{{member.canHelp || member.needHelp}}">
<view class="card-head">
<icon name="handshake" size="48" color="#00CED1" customClass="card-icon"></icon>
<text class="card-label">互助需求</text>
</view>
<view class="card-body">
<view class="help-box help-give" wx:if="{{member.canHelp}}">
<text class="help-tag">我能帮你</text>
<text class="help-txt">{{member.canHelp}}</text>
<!-- 底部:分享 + 双入口(同一视觉块 -->
<view class="footer-panel">
<view class="footer-pills">
<view class="pill pill-gold" bindtap="goToVip">
<icon name="sparkles" size="30" color="#FBBF24" customClass="pill-ic"></icon>
<text class="pill-txt">成为超级个体</text>
</view>
<view class="help-box help-need" wx:if="{{member.needHelp}}">
<text class="help-tag need">我需要帮助</text>
<text class="help-txt">{{member.needHelp}}</text>
<view class="pill pill-teal" bindtap="goToMatch">
<icon name="users" size="30" color="#5EEAD4" customClass="pill-ic"></icon>
<text class="pill-txt">找更多伙伴</text>
</view>
</view>
</view>
<!-- 项目介绍 -->
<view class="card" wx:if="{{member.project}}">
<view class="card-head">
<icon name="rocket" size="48" color="#00CED1" customClass="card-icon rocket"></icon>
<text class="card-label">项目介绍</text>
</view>
<text class="proj-txt">{{member.project}}</text>
</view>
<!-- 底部按钮 -->
<view class="bottom-wrap">
<view class="btn-super" bindtap="goToVip">
<text>成为超级个体</text>
<icon name="chevron-right" size="36" color="#F59E0B" customClass="btn-arrow"></icon>
</view>
</view>
<view style="height:160rpx;"></view>
<view class="scroll-pad"></view>
</scroll-view>
<!-- 加载和空状态 -->
<view class="state-wrap" wx:if="{{loading}}">
<view class="loading-dot"></view>
<text class="state-txt">加载中...</text>

View File

@@ -1,172 +1,629 @@
/* 卡若创业派对 - 个人资料页enhanced_professional_profile 1:1 还原 */
.page { background: #050B14; min-height: 100vh; color: #fff; }
/* 导航栏 */
.nav-bar {
position: fixed; top: 0; left: 0; right: 0; z-index: 999;
display: flex; align-items: center; justify-content: space-between;
padding: 0 32rpx; height: 44px;
background: rgba(5, 11, 20, 0.9);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
border-bottom: 1rpx solid rgba(255, 255, 255, 0.05);
/* 卡若创业派对 - 超级个体详情(高阶单页 · 深色玻璃态 */
.page {
background: radial-gradient(120% 80% at 50% -20%, rgba(20, 80, 90, 0.35) 0%, #050B14 45%);
min-height: 100vh;
color: #fff;
}
.nav-back { width: 80rpx; height: 80rpx; display: flex; align-items: center; justify-content: flex-start; }
.nav-icon { font-size: 44rpx; color: #5EEAD4; font-weight: 300; }
.nav-title { font-size: 34rpx; font-weight: 700; color: #fff; letter-spacing: 2rpx; }
.nav-right { display: flex; align-items: center; gap: 16rpx; }
.nav-icon-wrap { padding: 8rpx; }
.nav-icon-dot { font-size: 28rpx; color: rgba(255,255,255,0.8); }
.scroll-wrap { height: calc(100vh - 88px); }
/* —— 导航 —— */
.nav-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 999;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 28rpx;
height: 44px;
background: rgba(5, 11, 20, 0.72);
backdrop-filter: blur(20rpx);
-webkit-backdrop-filter: blur(20rpx);
border-bottom: 1rpx solid rgba(255, 255, 255, 0.06);
}
.nav-back {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: flex-start;
}
.nav-icon {
font-size: 44rpx;
color: #5eead4;
font-weight: 300;
}
.nav-title {
font-size: 32rpx;
font-weight: 600;
color: #f8fafc;
letter-spacing: 4rpx;
}
.nav-placeholder {
width: 72rpx;
}
/* ===== 顶部 Profile 卡片 ===== */
.card-profile {
position: relative; margin: 32rpx 32rpx 0;
padding: 64rpx 40rpx 48rpx;
.scroll-wrap {
box-sizing: border-box;
}
/* —— 首屏外壳(头像 + 链接列) —— */
.shell {
position: relative;
margin: 28rpx 24rpx 0;
padding: 40rpx 32rpx 36rpx;
border-radius: 32rpx;
background: #0F1720;
border: 1rpx solid rgba(255, 255, 255, 0.08);
background: linear-gradient(145deg, rgba(22, 36, 48, 0.95) 0%, rgba(12, 20, 32, 0.98) 100%);
border: 1rpx solid rgba(94, 234, 212, 0.12);
box-shadow: 0 24rpx 80rpx rgba(0, 0, 0, 0.45), inset 0 1rpx 0 rgba(255, 255, 255, 0.06);
overflow: hidden;
}
.profile-deco {
position: absolute; top: 0; left: 0; right: 0; height: 128rpx;
background: linear-gradient(180deg, rgba(30, 58, 69, 0.3) 0%, transparent 100%);
.shell-glow {
position: absolute;
top: -40%;
right: -20%;
width: 70%;
height: 80%;
background: radial-gradient(circle, rgba(45, 212, 191, 0.12) 0%, transparent 70%);
pointer-events: none;
}
.profile-body { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; }
.hero-profile {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
padding-bottom: 8rpx;
}
.hero-avatar-block {
display: flex;
flex-direction: column;
align-items: center;
max-width: 520rpx;
}
.hero-avatar-block-hover {
opacity: 0.94;
}
.contact-rows {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 12rpx;
margin-top: 32rpx;
}
.contact-rows-subtle {
margin-top: 24rpx;
padding-top: 24rpx;
border-top: 1rpx solid rgba(255, 255, 255, 0.06);
}
.avatar-outer {
position: relative;
width: 176rpx; height: 176rpx;
margin-bottom: 32rpx;
width: 168rpx;
height: 168rpx;
flex-shrink: 0;
}
.avatar-wrap {
position: relative;
width: 100%; height: 100%;
width: 100%;
height: 100%;
border-radius: 50%;
overflow: hidden;
border: 2rpx solid rgba(255, 255, 255, 0.1);
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.4);
border: 2rpx solid rgba(255, 255, 255, 0.12);
box-shadow: 0 12rpx 40rpx rgba(0, 0, 0, 0.35);
}
.avatar-wrap.vip-ring {
border: 4rpx solid transparent;
background: linear-gradient(135deg, #F59E0B, #5EEAD4, #F59E0B);
border: 3rpx solid transparent;
background: linear-gradient(135deg, #f59e0b, #5eead4, #f59e0b);
background-size: 200% 200%;
animation: vipGlow 4s ease infinite;
animation: vipGlow 5s ease infinite;
}
@keyframes vipGlow {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.avatar-img { width: 100%; height: 100%; object-fit: cover; }
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-ph {
width: 100%; height: 100%;
background: #17212F;
display: flex; align-items: center; justify-content: center;
font-size: 56rpx; color: #5EEAD4; font-weight: 700;
width: 100%;
height: 100%;
background: #1a2332;
display: flex;
align-items: center;
justify-content: center;
font-size: 52rpx;
color: #5eead4;
font-weight: 700;
}
.vip-tag {
position: absolute; bottom: -4rpx; right: -4rpx;
background: linear-gradient(135deg, #F59E0B, #e8920d);
color: #000; font-size: 20rpx; font-weight: 800;
padding: 6rpx 14rpx; border-radius: 16rpx;
position: absolute;
bottom: -2rpx;
right: -2rpx;
background: linear-gradient(135deg, #fbbf24, #d97706);
color: #0f172a;
font-size: 18rpx;
font-weight: 800;
padding: 6rpx 12rpx;
border-radius: 12rpx;
z-index: 2;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.3);
box-shadow: 0 4rpx 14rpx rgba(0, 0, 0, 0.35);
}
.profile-name { font-size: 40rpx; font-weight: 700; color: #fff; margin-bottom: 24rpx; letter-spacing: 2rpx; }
.profile-tags { display: flex; align-items: center; justify-content: center; gap: 24rpx; flex-wrap: wrap; }
.tag { font-size: 24rpx; font-weight: 500; padding: 8rpx 24rpx; border-radius: 999rpx; }
.tag-mbti { background: #134E4A; color: #5EEAD4; border: 1rpx solid rgba(94, 234, 212, 0.2); }
.tag-region { background: #1F2937; color: #D1D5DB; border: 1rpx solid rgba(255, 255, 255, 0.1); display: flex; align-items: center; gap: 8rpx; }
.pin-icon { color: #EF4444; font-size: 22rpx; }
/* ===== 通用卡片 ===== */
.card {
margin: 32rpx;
padding: 40rpx 40rpx;
border-radius: 32rpx;
background: #0F1720;
border: 1rpx solid rgba(255, 255, 255, 0.08);
.link-chip {
display: flex;
flex-direction: row;
align-items: center;
gap: 20rpx;
padding: 22rpx 22rpx;
border-radius: 20rpx;
background: rgba(15, 23, 42, 0.65);
border: 1rpx solid rgba(148, 163, 184, 0.15);
transition: border-color 0.2s;
}
.card-head { display: flex; align-items: center; gap: 20rpx; margin-bottom: 40rpx; }
.card-icon { font-size: 40rpx; }
.card-icon.bulb { filter: sepia(1) saturate(3) hue-rotate(15deg); }
.card-icon.rocket { opacity: 0.9; }
.card-label { font-size: 30rpx; font-weight: 700; color: #fff; letter-spacing: 1rpx; }
.card-body { }
.field { margin-bottom: 32rpx; }
.field:last-child { margin-bottom: 0; }
.f-key { display: block; font-size: 26rpx; color: #94A3B8; margin-bottom: 12rpx; }
.f-val { font-size: 30rpx; font-weight: 500; color: #fff; line-height: 1.6; }
.f-val.mono { font-family: ui-monospace, monospace; letter-spacing: 2rpx; }
.f-row { display: flex; align-items: center; gap: 16rpx; }
.icon-copy { font-size: 36rpx; color: #94A3B8; opacity: 0.6; padding: 8rpx; }
.icon-eye-off { display: flex; align-items: center; justify-content: center; }
.icon-eye-off .icon-img { width: 40rpx; height: 40rpx; }
.divider { height: 1rpx; background: rgba(255, 255, 255, 0.05); margin: 32rpx 0; }
/* ===== 个人故事 ===== */
.story { margin-bottom: 32rpx; }
.story:last-child { margin-bottom: 0; }
.story-head { display: flex; align-items: center; gap: 12rpx; margin-bottom: 12rpx; }
.story-icon { font-size: 32rpx; }
.story-icon.turn { opacity: 0.9; }
.story-q { font-size: 26rpx; font-weight: 500; color: #94A3B8; }
.story-a { display: block; font-size: 28rpx; color: #E5E7EB; line-height: 1.7; }
/* ===== 互助需求 ===== */
.help-box {
padding: 32rpx;
border-radius: 24rpx;
margin-bottom: 24rpx;
background: #17212F;
border: 1rpx solid rgba(255, 255, 255, 0.05);
.link-chip-subtle {
gap: 16rpx;
padding: 14rpx 8rpx 14rpx 12rpx;
border-radius: 0;
background: transparent;
border: none;
border-bottom: 1rpx solid rgba(255, 255, 255, 0.05);
}
.help-box:last-child { margin-bottom: 0; }
.help-tag {
display: inline-block;
font-size: 22rpx; font-weight: 600;
padding: 6rpx 16rpx; border-radius: 12rpx;
margin-bottom: 16rpx;
.link-chip-subtle:last-of-type {
border-bottom: none;
}
.help-give .help-tag { color: #5EEAD4; background: #112D2A; }
.help-need .help-tag { color: #F59E0B; background: #2D1F0D; }
.help-txt { display: block; font-size: 26rpx; color: #fff; line-height: 1.6; letter-spacing: 1rpx; }
/* ===== 项目介绍 ===== */
.proj-txt { font-size: 28rpx; color: #E5E7EB; line-height: 1.7; }
/* ===== 底部按钮 ===== */
.bottom-wrap {
padding: 48rpx 32rpx 0;
.link-chip-subtle:active {
background: rgba(255, 255, 255, 0.03);
}
.btn-super {
.link-chip-open.link-chip-subtle {
border-bottom-color: rgba(255, 255, 255, 0.05);
}
.link-chip:active {
border-color: rgba(94, 234, 212, 0.35);
background: rgba(15, 30, 40, 0.75);
}
.link-chip-subtle:active {
border-color: transparent;
}
.link-chip-open {
border-color: rgba(94, 234, 212, 0.25);
}
.link-chip-icon {
width: 64rpx;
height: 64rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.link-chip-icon-subtle {
width: 48rpx;
height: 48rpx;
border-radius: 12rpx;
background: rgba(255, 255, 255, 0.04);
}
.link-chip-icon-phone {
background: rgba(45, 212, 191, 0.12);
}
.link-chip-icon-wx {
background: rgba(52, 211, 153, 0.12);
}
.link-chip-icon-subtle.link-chip-icon-phone,
.link-chip-icon-subtle.link-chip-icon-wx {
background: rgba(255, 255, 255, 0.04);
}
.lc-ic {
display: block;
}
.link-chip-main {
flex: 1;
min-width: 0;
}
.link-chip-label {
display: block;
font-size: 20rpx;
color: #94a3b8;
margin-bottom: 6rpx;
}
.link-chip-label-subtle {
font-size: 18rpx;
color: rgba(100, 116, 139, 0.85);
margin-bottom: 4rpx;
}
.link-chip-val {
display: block;
font-size: 26rpx;
font-weight: 600;
color: #f8fafc;
line-height: 1.35;
word-break: break-all;
}
.link-chip-val-subtle {
font-size: 22rpx;
font-weight: 400;
color: rgba(148, 163, 184, 0.92);
}
.link-chip-val.mono {
font-family: ui-monospace, monospace;
letter-spacing: 1rpx;
}
.link-chip-action {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 4rpx;
font-size: 22rpx;
font-weight: 600;
color: #5eead4;
}
.link-chip-action-subtle {
font-size: 20rpx;
font-weight: 500;
color: rgba(100, 116, 139, 0.95);
}
.lc-arr {
display: block;
}
.link-empty {
padding: 24rpx;
border-radius: 20rpx;
background: rgba(15, 23, 42, 0.4);
border: 1rpx dashed rgba(148, 163, 184, 0.2);
}
.link-empty-subtle {
padding: 16rpx 8rpx;
background: transparent;
border: none;
}
.link-empty-txt {
font-size: 24rpx;
color: #64748b;
}
.link-empty-subtle .link-empty-txt {
font-size: 22rpx;
color: rgba(100, 116, 139, 0.75);
}
.profile-name {
position: relative;
z-index: 1;
display: block;
text-align: center;
margin-top: 20rpx;
width: 100%;
font-size: 32rpx;
font-weight: 700;
color: #fff;
letter-spacing: 2rpx;
line-height: 1.35;
word-break: break-all;
}
.profile-tags {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
flex-wrap: wrap;
margin-top: 16rpx;
width: 100%;
padding: 32rpx 0;
border-radius: 999rpx;
background: transparent;
border: 1rpx solid rgba(245, 158, 11, 0.3);
color: #F59E0B;
font-size: 30rpx; font-weight: 500;
letter-spacing: 2rpx;
}
.btn-arrow { font-size: 36rpx; font-weight: 300; }
.profile-tags-modern {
gap: 10rpx;
margin-top: 18rpx;
}
.tag {
font-size: 22rpx;
font-weight: 500;
padding: 8rpx 20rpx;
border-radius: 999rpx;
}
.profile-tags-modern .tag-mbti {
font-size: 20rpx;
font-weight: 600;
letter-spacing: 2rpx;
padding: 6rpx 18rpx;
background: rgba(45, 212, 191, 0.1);
color: #7ee8dc;
border: 1rpx solid rgba(94, 234, 212, 0.2);
}
.profile-tags-modern .tag-region {
font-size: 20rpx;
padding: 6rpx 16rpx 6rpx 14rpx;
background: rgba(255, 255, 255, 0.06);
color: rgba(203, 213, 225, 0.95);
border: 1rpx solid rgba(255, 255, 255, 0.08);
}
.tag-mbti {
background: rgba(19, 78, 74, 0.6);
color: #5eead4;
border: 1rpx solid rgba(94, 234, 212, 0.25);
}
.tag-region {
background: rgba(30, 41, 59, 0.8);
color: #cbd5e1;
border: 1rpx solid rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
gap: 8rpx;
}
.pin-icon {
color: #f87171;
font-size: 22rpx;
}
/* ===== 状态 ===== */
.state-wrap { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 60vh; gap: 24rpx; }
.state-txt { font-size: 28rpx; color: #64748B; }
.state-emoji { font-size: 96rpx; }
/* —— 一体化信息卡 —— */
.mono-card {
margin: 24rpx 24rpx 0;
padding: 8rpx 0 32rpx;
border-radius: 32rpx;
background: rgba(15, 23, 34, 0.88);
border: 1rpx solid rgba(255, 255, 255, 0.07);
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.25);
}
.mono-card-compact {
padding-bottom: 24rpx;
}
.mono-sec {
padding: 28rpx 32rpx 8rpx;
}
.mono-sec-tight {
padding: 18rpx 28rpx 6rpx;
}
.mono-sec-head {
margin-bottom: 24rpx;
}
.mono-sec-head-tight {
margin-bottom: 14rpx;
}
.mono-sec-kicker {
display: block;
font-size: 18rpx;
font-weight: 700;
color: rgba(94, 234, 212, 0.55);
letter-spacing: 6rpx;
margin-bottom: 8rpx;
}
.mono-sec-title {
font-size: 30rpx;
font-weight: 700;
color: #f8fafc;
}
.mono-divider {
height: 1rpx;
margin: 4rpx 28rpx;
background: linear-gradient(90deg, transparent, rgba(148, 163, 184, 0.15), transparent);
}
.kv-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 16rpx 20rpx;
}
.kv-cell {
width: calc(50% - 10rpx);
box-sizing: border-box;
}
.kv-cell-full {
width: 100%;
}
.kv-cell .kv-k {
display: block;
font-size: 20rpx;
color: #64748b;
margin-bottom: 6rpx;
}
.kv-cell .kv-v {
font-size: 26rpx;
color: #e2e8f0;
line-height: 1.5;
font-weight: 500;
}
.kv {
margin-bottom: 28rpx;
}
.kv:last-child {
margin-bottom: 8rpx;
}
.kv-k {
display: block;
font-size: 22rpx;
color: #64748b;
margin-bottom: 10rpx;
}
.kv-v {
font-size: 28rpx;
color: #e2e8f0;
line-height: 1.65;
font-weight: 500;
}
.skills-showcase .skills-quote {
padding: 20rpx 22rpx 20rpx 20rpx;
border-radius: 18rpx;
background: linear-gradient(105deg, rgba(45, 212, 191, 0.08) 0%, rgba(15, 23, 42, 0.5) 100%);
border-left: 6rpx solid #2dd4bf;
box-shadow: inset 0 0 0 1rpx rgba(45, 212, 191, 0.12);
}
.skills-quote-text {
font-size: 26rpx;
color: #f1f5f9;
line-height: 1.6;
font-weight: 500;
}
.story {
margin-bottom: 8rpx;
}
.story-compact .story-head {
margin-bottom: 8rpx;
}
.story-compact .story-a {
font-size: 26rpx;
line-height: 1.55;
padding-left: 0;
}
.story-gap {
height: 28rpx;
}
.story-gap-tight {
height: 16rpx;
}
.story-head {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 12rpx;
}
.story-icon {
flex-shrink: 0;
}
.story-q {
font-size: 22rpx;
font-weight: 600;
color: #94a3b8;
}
.story-a {
display: block;
font-size: 28rpx;
color: #e2e8f0;
line-height: 1.7;
padding-left: 4rpx;
}
.help-grid {
display: flex;
flex-direction: column;
gap: 14rpx;
}
.help-tile {
padding: 22rpx 24rpx;
border-radius: 20rpx;
background: rgba(23, 33, 47, 0.9);
border: 1rpx solid rgba(255, 255, 255, 0.06);
}
.help-tile-tag {
display: inline-block;
font-size: 20rpx;
font-weight: 700;
padding: 8rpx 18rpx;
border-radius: 12rpx;
margin-bottom: 16rpx;
}
.help-give .help-tile-tag {
color: #5eead4;
background: rgba(6, 78, 59, 0.45);
border: 1rpx solid rgba(45, 212, 191, 0.2);
}
.help-need .help-tile-tag.need {
color: #fbbf24;
background: rgba(69, 47, 8, 0.45);
border: 1rpx solid rgba(251, 191, 36, 0.2);
}
.help-tile-txt {
font-size: 26rpx;
color: #f1f5f9;
line-height: 1.55;
}
.proj-body {
font-size: 28rpx;
color: #cbd5e1;
line-height: 1.75;
padding-bottom: 8rpx;
}
.proj-body-compact {
font-size: 26rpx;
line-height: 1.55;
}
/* —— 底栏:分享 + 双入口 —— */
.footer-panel {
margin: 32rpx 24rpx 0;
padding: 28rpx;
border-radius: 28rpx;
background: rgba(12, 18, 28, 0.92);
border: 1rpx solid rgba(94, 234, 212, 0.1);
box-shadow: 0 12rpx 40rpx rgba(0, 0, 0, 0.3);
}
.footer-pills {
display: flex;
flex-direction: row;
gap: 16rpx;
}
.pill {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 10rpx;
padding: 22rpx 12rpx;
border-radius: 20rpx;
background: rgba(30, 41, 59, 0.6);
border: 1rpx solid rgba(255, 255, 255, 0.08);
}
.pill:active {
background: rgba(51, 65, 85, 0.7);
}
.pill-gold {
border-color: rgba(251, 191, 36, 0.2);
}
.pill-teal {
border-color: rgba(94, 234, 212, 0.2);
}
.pill-ic {
flex-shrink: 0;
}
.pill-txt {
font-size: 24rpx;
font-weight: 600;
color: rgba(248, 250, 252, 0.88);
}
.scroll-pad {
height: calc(120rpx + env(safe-area-inset-bottom));
}
/* —— 状态 —— */
.state-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
gap: 24rpx;
}
.state-txt {
font-size: 28rpx;
color: #64748b;
}
.state-emoji {
font-size: 96rpx;
}
.loading-dot {
width: 56rpx; height: 56rpx;
width: 56rpx;
height: 56rpx;
border-radius: 50%;
border: 4rpx solid rgba(94, 234, 212, 0.2);
border-top-color: #5EEAD4;
border-top-color: #5eead4;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View File

@@ -7,6 +7,22 @@
const app = getApp()
const { formatStatNum } = require('../../utils/util.js')
const { trackClick } = require('../../utils/trackClick')
const { cleanSingleLineField } = require('../../utils/contentParser.js')
/** 是否视为「单章解锁」类订单(排除全书/VIP 等聚合商品名) */
function isSectionUnlockOrder(o) {
const name = String(o.product_name || o.title || '').trim()
if (/全书|全書|VIP|会员|年费|买断/.test(name)) return false
const pid = String(o.product_id || o.section_id || o.sectionId || '')
if (/^\d+\.\d+/.test(pid)) return true
return !!pid && pid.length > 0
}
function parseOrderTimeMs(o) {
const raw = o.created_at || o.createdAt || o.pay_time || 0
const t = new Date(raw).getTime()
return Number.isFinite(t) ? t : 0
}
Page({
data: {
@@ -33,6 +49,8 @@ Page({
readCountText: '0',
totalReadTimeText: '0',
matchHistoryText: '0',
orderCountText: '0',
giftPayCountText: '0',
// 最近阅读
recentChapters: [],
@@ -78,6 +96,11 @@ Page({
// 我的余额
walletBalanceText: '--',
// 已解锁章节(订单倒序;默认最多展示 5 条,底部倒三角展开)
unlockedChaptersFull: [],
displayUnlockedChapters: [],
unlockedExpanded: false,
},
onLoad() {
@@ -159,6 +182,7 @@ Page({
this.loadPendingConfirm()
this.loadVipStatus()
this.loadWalletBalance()
this.loadUnlockedChapters()
} else {
const guestReadCount = app.getReadCount()
this.setData({
@@ -172,6 +196,9 @@ Page({
pendingEarnings: '-',
earningsLoading: false,
recentChapters: [],
unlockedChaptersFull: [],
displayUnlockedChapters: [],
unlockedExpanded: false,
totalReadTime: 0,
matchHistory: 0,
totalReadTimeText: '0',
@@ -180,6 +207,91 @@ Page({
}
},
/**
* 已解锁章节:优先订单接口(按支付时间倒序);失败时用 purchasedSections + bookData 兜底
*/
async loadUnlockedChapters() {
if (!app.globalData.isLoggedIn || !app.globalData.userInfo?.id) {
this.setData({
unlockedChaptersFull: [],
displayUnlockedChapters: [],
unlockedExpanded: false
})
return
}
const userId = app.globalData.userInfo.id
const expanded = this.data.unlockedExpanded
const bookFlat = Array.isArray(app.globalData.bookData) ? app.globalData.bookData : []
const metaById = (id) => {
const row = bookFlat.find((s) => s.id === id)
return {
mid: row?.mid ?? row?.MID ?? 0,
title: cleanSingleLineField(row?.sectionTitle || row?.section_title || row?.title || row?.chapterTitle || '')
}
}
try {
const res = await app.request({ url: `/api/miniprogram/orders?userId=${encodeURIComponent(userId)}`, silent: true })
let rows = []
if (res && res.success && Array.isArray(res.data)) {
rows = res.data
.map((item) => ({
id: item.product_id || item.section_id,
mid: item.section_mid ?? item.mid ?? item.MID ?? 0,
title: cleanSingleLineField(item.product_name || ''),
_ts: parseOrderTimeMs(item)
}))
.filter((r) => r.id && isSectionUnlockOrder({ product_id: r.id, product_name: r.title }))
}
rows.sort((a, b) => b._ts - a._ts)
const seen = new Set()
const deduped = []
for (const r of rows) {
if (seen.has(r.id)) continue
seen.add(r.id)
const meta = metaById(r.id)
deduped.push({
id: r.id,
mid: r.mid || meta.mid,
title: cleanSingleLineField(r.title || meta.title || `章节 ${r.id}`)
})
}
if (deduped.length === 0) {
const ids = [...(app.globalData.purchasedSections || [])]
ids.reverse()
for (const id of ids) {
if (seen.has(id)) continue
seen.add(id)
const meta = metaById(id)
deduped.push({ id, mid: meta.mid, title: cleanSingleLineField(meta.title || `章节 ${id}`) })
}
}
const display = expanded ? deduped : deduped.slice(0, 5)
this.setData({ unlockedChaptersFull: deduped, displayUnlockedChapters: display })
} catch (e) {
const ids = [...(app.globalData.purchasedSections || [])].reverse()
const seen = new Set()
const deduped = []
for (const id of ids) {
if (!id || seen.has(id)) continue
seen.add(id)
const meta = metaById(id)
deduped.push({ id, mid: meta.mid, title: cleanSingleLineField(meta.title || `章节 ${id}`) })
}
const display = expanded ? deduped : deduped.slice(0, 5)
this.setData({ unlockedChaptersFull: deduped, displayUnlockedChapters: display })
}
},
expandUnlockedChapters() {
if (this.data.unlockedExpanded) return
trackClick('my', 'tab_click', '已解锁章节_展开')
const full = this.data.unlockedChaptersFull || []
this.setData({
unlockedExpanded: true,
displayUnlockedChapters: full
})
},
async loadDashboardStats() {
const userId = app.globalData.userInfo?.id
if (!userId) return
@@ -207,6 +319,8 @@ Page({
const readCount = Number(res.data.readCount || 0)
const totalReadTime = Number(res.data.totalReadMinutes || 0)
const matchHistory = Number(res.data.matchHistory || 0)
const orderCount = Number(res.data.orderCount || 0)
const giftPayCount = Number(res.data.giftPayCount || 0)
this.setData({
readCount,
totalReadTime,
@@ -214,6 +328,8 @@ Page({
readCountText: formatStatNum(readCount),
totalReadTimeText: formatStatNum(totalReadTime),
matchHistoryText: formatStatNum(matchHistory),
orderCountText: formatStatNum(orderCount),
giftPayCountText: formatStatNum(giftPayCount),
recentChapters
})
} catch (e) {

View File

@@ -49,20 +49,20 @@
</view>
<view class="profile-stats-row">
<view class="profile-stat" bindtap="goToChapters">
<text class="profile-stat-val">{{readCountText}}</text>
<text class="profile-stat-val">{{readCountText || '0'}}</text>
<text class="profile-stat-label">已读章节</text>
</view>
<view class="profile-stat" wx:if="{{referralEnabled}}" bindtap="goToReferral">
<text class="profile-stat-val">{{referralCount}}</text>
<text class="profile-stat-label">推荐好友</text>
</view>
<view class="profile-stat" wx:if="{{referralEnabled}}" bindtap="goToReferral">
<text class="profile-stat-val">{{pendingEarnings === '-' ? '--' : pendingEarnings}}</text>
<text class="profile-stat-label">我的收益</text>
<view class="profile-stat" wx:if="{{!auditMode}}" bindtap="goToMatch">
<text class="profile-stat-val">{{matchHistoryText}}</text>
<text class="profile-stat-label">匹配伙伴</text>
</view>
<view class="profile-stat" wx:if="{{!auditMode}}" bindtap="handleMenuTap" data-id="wallet">
<text class="profile-stat-val">{{walletBalanceText}}</text>
<text class="profile-stat-label">我的余额</text>
<view class="profile-stat" wx:if="{{!auditMode}}" bindtap="goToReferral">
<text class="profile-stat-val">{{pendingEarnings || '0.00'}}</text>
<text class="profile-stat-label">我的收益</text>
</view>
</view>
</view>
@@ -92,31 +92,63 @@
</view>
</view>
<!-- 阅读统计 -->
<!-- 快捷入口:我的订单 + 我的代付 -->
<view class="card stats-card">
<view class="card-header">
<image class="card-icon-img" src="/assets/icons/eye-teal.svg" mode="aspectFit"/>
<text class="card-title">阅读统计</text>
<text class="card-title">快捷入口</text>
</view>
<view class="stats-grid">
<view class="stat-box" bindtap="goToChapters">
<image class="stat-icon-img" src="/assets/icons/book-open-teal.svg" mode="aspectFit"/>
<text class="stat-num">{{readCountText}}</text>
<text class="stat-label">已读章节</text>
<view class="stat-box" bindtap="handleMenuTap" data-id="orders">
<image class="stat-icon-img" src="/assets/icons/list-teal.svg" mode="aspectFit"/>
<text class="stat-num">订单</text>
<text class="stat-label">我的订单</text>
</view>
<view class="stat-box" bindtap="goToChapters">
<image class="stat-icon-img" src="/assets/icons/clock-teal.svg" mode="aspectFit"/>
<text class="stat-num">{{totalReadTimeText}}</text>
<text class="stat-label">阅读分钟</text>
<view class="stat-box" bindtap="handleMenuTap" data-id="giftPay">
<image class="stat-icon-img" src="/assets/icons/share-teal.svg" mode="aspectFit"/>
<text class="stat-num">代付</text>
<text class="stat-label">我的代付</text>
</view>
<view class="stat-box" bindtap="goToMatch">
<image class="stat-icon-img" src="/assets/icons/users-teal.svg" mode="aspectFit"/>
<text class="stat-num">{{matchHistoryText}}</text>
<text class="stat-label">匹配伙伴</text>
<view class="stat-box" bindtap="handleMenuTap" data-id="wallet">
<image class="stat-icon-img" src="/assets/icons/wallet-teal.svg" mode="aspectFit"/>
<text class="stat-num">{{walletBalanceText}}</text>
<text class="stat-label">我的余额</text>
</view>
</view>
</view>
<!-- 已解锁:仅低调图标区(无标题文案);默认 5 条 + 倒三角展开;倒序由接口/JS 保证 -->
<view class="card recent-card unlocked-card" wx:if="{{unlockedChaptersFull.length > 0}}">
<view class="unlocked-section-head">
<image class="unlocked-section-icon" src="/assets/icons/unlock-muted-teal.svg" mode="aspectFit"/>
</view>
<view class="recent-list">
<view
class="recent-item"
wx:for="{{displayUnlockedChapters}}"
wx:key="id"
bindtap="goToRead"
data-id="{{item.id}}"
data-mid="{{item.mid}}"
>
<view class="recent-left">
<text class="recent-index">{{index + 1}}</text>
<text class="recent-title">{{item.title}}</text>
</view>
<text class="recent-link">阅读</text>
</view>
</view>
<view
class="unlocked-expand-hint"
wx:if="{{unlockedChaptersFull.length > 5 && !unlockedExpanded}}"
bindtap="expandUnlockedChapters"
hover-class="unlocked-expand-hint-hover"
hover-stay-time="80"
>
<view class="unlocked-expand-triangle"></view>
</view>
</view>
<!-- 最近阅读 -->
<view class="card recent-card">
<view class="card-header">
@@ -145,23 +177,9 @@
</view>
</view>
<!-- 我的订单 + 设置 -->
<view class="card menu-card">
<view class="menu-item" bindtap="handleMenuTap" data-id="orders">
<view class="menu-left">
<view class="menu-icon-wrap icon-teal"><image class="menu-icon-img" src="/assets/icons/folder-teal.svg" mode="aspectFit"/></view>
<text class="menu-text">我的订单</text>
</view>
<icon name="chevron-right" size="28" color="rgba(255,255,255,0.35)" customClass="menu-arrow"></icon>
</view>
<view class="menu-item" bindtap="handleMenuTap" data-id="giftPay">
<view class="menu-left">
<view class="menu-icon-wrap icon-gold"><image class="menu-icon-img" src="/assets/icons/gift.svg" mode="aspectFit"/></view>
<text class="menu-text">我的代付</text>
</view>
<icon name="chevron-right" size="28" color="rgba(255,255,255,0.35)" customClass="menu-arrow"></icon>
</view>
<view class="menu-item" wx:if="{{showSettingsEntry}}" bindtap="handleMenuTap" data-id="settings">
<!-- 设置 -->
<view class="card menu-card" wx:if="{{showSettingsEntry}}">
<view class="menu-item" bindtap="handleMenuTap" data-id="settings">
<view class="menu-left">
<view class="menu-icon-wrap icon-gray"><image class="menu-icon-img" src="/assets/icons/settings-gray.svg" mode="aspectFit"/></view>
<text class="menu-text">设置</text>

View File

@@ -162,7 +162,20 @@
display: flex; align-items: center; justify-content: space-between;
padding: 24rpx; background: #252525; border-radius: 20rpx;
}
.recent-left { display: flex; align-items: center; gap: 24rpx; overflow: hidden; }
.recent-left { display: flex; align-items: center; gap: 24rpx; overflow: hidden; min-width: 0; }
/* 已解锁区块:仅顶部弱对比图标,无标题字 */
.unlocked-card { padding-top: 28rpx; }
.unlocked-section-head {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 0 8rpx 16rpx 8rpx;
}
.unlocked-section-icon {
width: 40rpx;
height: 40rpx;
opacity: 0.92;
}
.recent-index { font-size: 28rpx; color: #6B7280; font-family: monospace; }
.recent-title { font-size: 28rpx; color: #E5E7EB; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.recent-link { font-size: 24rpx; color: #4FD1C5; font-weight: 500; flex-shrink: 0; }
@@ -170,6 +183,25 @@
.recent-empty-text { font-size: 28rpx; color: #6B7280; display: block; margin-bottom: 24rpx; }
.recent-empty-btn { font-size: 28rpx; color: #4FD1C5; }
/* 已解锁章节列表底部倒三角展开(与首页「最新新增」一致) */
.unlocked-expand-hint {
display: flex;
justify-content: center;
align-items: center;
padding: 8rpx 0 8rpx;
margin-top: 8rpx;
}
.unlocked-expand-hint-hover {
opacity: 0.65;
}
.unlocked-expand-triangle {
width: 0;
height: 0;
border-left: 16rpx solid transparent;
border-right: 16rpx solid transparent;
border-top: 20rpx solid rgba(79, 209, 197, 0.85);
}
/* 菜单 */
.menu-card { padding: 0; margin-bottom: 48rpx; overflow: hidden; }
.menu-item {

View File

@@ -1,135 +1,175 @@
<!-- 个人资料展示页 - enhanced_professional_profile 1:1 重构 -->
<!-- 卡若创业派对 - 个人资料展示页(与 member-detail 同一视觉) -->
<view class="page">
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-back" bindtap="goBack"><icon name="chevron-left" size="44" color="#5EEAD4" customClass="back-icon"></icon></view>
<view class="nav-back" bindtap="goBack">
<icon name="chevron-left" size="44" color="#5EEAD4" customClass="nav-icon"></icon>
</view>
<text class="nav-title">个人资料</text>
<view class="nav-right" bindtap="goToEdit"><text class="nav-more">⋯</text></view>
<view class="nav-edit" bindtap="goToEdit">
<icon name="edit" size="32" color="#5EEAD4"></icon>
</view>
</view>
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<view class="loading" wx:if="{{loading}}">加载中...</view>
<scroll-view wx:else class="scroll-main" scroll-y>
<!-- 头像区卡片 -->
<view class="hero-card" wx:if="{{profile}}">
<view class="hero-gradient"></view>
<view class="hero-content">
<view class="hero-avatar">
<image wx:if="{{profile.avatar}}" class="avatar-img" src="{{profile.avatar}}" mode="aspectFill"/>
<view wx:else class="avatar-placeholder">{{profile.nickname ? profile.nickname[0] : '?'}}</view>
</view>
<text class="hero-name">{{profile.nickname || '未设置昵称'}}</text>
<view class="hero-tags">
<text class="tag tag-mbti" wx:if="{{profile.mbti}}">{{profile.mbti}}</text>
<view class="tag tag-region" wx:if="{{profile.region}}"><icon name="map-pin" size="24" color="currentColor" customClass="tag-icon"></icon><text>{{profile.region}}</text></view>
</view>
</view>
</view>
<view class="state-wrap" wx:if="{{loading}}">
<view class="loading-dot"></view>
<text class="state-txt">加载中...</text>
</view>
<!-- 基本信息 -->
<view class="section">
<view class="section-head">
<icon name="user" size="40" color="#00CED1" customClass="section-icon"></icon>
<text class="section-title">基本信息</text>
</view>
<view class="section-body">
<view class="field" wx:if="{{profile.industry}}">
<text class="field-label">行业</text>
<text class="field-value">{{profile.industry}}</text>
</view>
<view class="field" wx:if="{{profile.position}}">
<text class="field-label">职位</text>
<text class="field-value">{{profile.position}}</text>
</view>
<view class="field" wx:if="{{profile.businessScale}}">
<text class="field-label">业务体量</text>
<text class="field-value">{{profile.businessScale}}</text>
</view>
<view class="field-divider" wx:if="{{profile.industry || profile.position || profile.businessScale}}"></view>
<view class="field" wx:if="{{profile.skills}}">
<text class="field-label">我擅长</text>
<text class="field-value">{{profile.skills}}</text>
</view>
<view class="field" wx:if="{{profile.phoneMask || profile.phone}}">
<text class="field-label">联系方式</text>
<view class="field-value-row" bindtap="copyPhone">
<text class="field-value mono">{{profile.phoneMask || profile.phone || '未填写'}}</text>
<text class="field-hint" wx:if="{{profile.phone}}">复制</text>
<scroll-view scroll-y class="scroll-wrap" style="height: calc(100vh - {{statusBarHeight + 44}}px - 120rpx);" wx:if="{{!loading && profile}}">
<!-- 首屏壳:头像 + 联系方式 -->
<view class="shell">
<view class="shell-glow"></view>
<view class="hero-row">
<view class="avatar-outer">
<view class="avatar-wrap">
<image class="avatar-img" wx:if="{{profile.avatar}}" src="{{profile.avatar}}" mode="aspectFill"/>
<view class="avatar-ph" wx:else><text>{{profile.nickname ? profile.nickname[0] : '?'}}</text></view>
</view>
</view>
<view class="field" wx:if="{{profile.wechatMask || profile.wechat}}">
<text class="field-label">微信号</text>
<view class="field-value-row" bindtap="copyWechat">
<text class="field-value mono">{{profile.wechatMask || profile.wechat || '未填写'}}</text>
<text class="field-hint" wx:if="{{profile.wechat}}">复制</text>
<view class="link-column">
<text class="link-column-title">我的联系方式</text>
<view class="link-chip" wx:if="{{profile.phone}}" bindtap="copyPhone">
<view class="link-chip-icon link-chip-icon-phone">
<icon name="smartphone" size="34" color="#5EEAD4" customClass="lc-ic"></icon>
</view>
<view class="link-chip-main">
<text class="link-chip-label">手机号</text>
<text class="link-chip-val mono">{{profile.phone}}</text>
</view>
<view class="link-chip-action"><text>复制</text></view>
</view>
<view class="link-chip" wx:if="{{profile.wechat}}" bindtap="copyWechat">
<view class="link-chip-icon link-chip-icon-wx">
<icon name="message-circle" size="34" color="#34D399" customClass="lc-ic"></icon>
</view>
<view class="link-chip-main">
<text class="link-chip-label">微信号</text>
<text class="link-chip-val mono">{{profile.wechat}}</text>
</view>
<view class="link-chip-action"><text>复制</text></view>
</view>
<view class="link-empty" wx:if="{{!profile.phone && !profile.wechat}}">
<text class="link-empty-txt">未填写联系方式</text>
</view>
</view>
<view class="field-empty" wx:if="{{!profile.industry && !profile.position && !profile.businessScale && !profile.skills && !profile.phone && !profile.wechat}}">
点击右上角 ⋯ 编辑完善资料
</view>
<text class="profile-name">{{profile.nickname || '未设置昵称'}}</text>
<view class="profile-tags" wx:if="{{profile.mbti || profile.region}}">
<text class="tag tag-mbti" wx:if="{{profile.mbti}}">{{profile.mbti}}</text>
<view class="tag tag-region" wx:if="{{profile.region}}">
<icon name="map-pin" size="24" color="currentColor" customClass="pin-icon"></icon>
<text>{{profile.region}}</text>
</view>
</view>
</view>
<!-- 个人故事 -->
<view class="section" wx:if="{{profile.storyBestMonth || profile.storyAchievement || profile.storyTurning}}">
<view class="section-head">
<icon name="lightbulb" size="40" color="#FFD700" customClass="section-icon section-icon-yellow"></icon>
<text class="section-title">个人故事</text>
<!-- 一体化信息卡 -->
<view class="mono-card" wx:if="{{profile.industry || profile.position || profile.businessScale || profile.skills || profile.storyBestMonth || profile.storyAchievement || profile.storyTurning || profile.helpOffer || profile.helpNeed || profile.projectIntro}}">
<!-- 职业画像 -->
<view class="mono-sec" wx:if="{{profile.industry || profile.position || profile.businessScale}}">
<view class="mono-sec-head">
<text class="mono-sec-title">职业画像</text>
</view>
<view class="kv" wx:if="{{profile.industry}}">
<text class="kv-k">行业</text>
<text class="kv-v">{{profile.industry}}</text>
</view>
<view class="kv" wx:if="{{profile.position}}">
<text class="kv-k">职位</text>
<text class="kv-v">{{profile.position}}</text>
</view>
<view class="kv" wx:if="{{profile.businessScale}}">
<text class="kv-k">业务体量</text>
<text class="kv-v">{{profile.businessScale}}</text>
</view>
</view>
<view class="section-body">
<view class="story-block" wx:if="{{profile.storyBestMonth}}">
<view class="story-head"><icon name="trophy" size="28" color="#FFD700" customClass="story-emoji"></icon><text class="story-label">最赚钱的一个月做的是什么</text></view>
<text class="story-text">{{profile.storyBestMonth}}</text>
<view class="mono-divider" wx:if="{{(profile.industry || profile.position || profile.businessScale) && profile.skills}}"></view>
<!-- 我擅长 -->
<view class="mono-sec skills-showcase" wx:if="{{profile.skills}}">
<view class="mono-sec-head">
<text class="mono-sec-title">我擅长</text>
</view>
<view class="field-divider" wx:if="{{profile.storyBestMonth && (profile.storyAchievement || profile.storyTurning)}}"></view>
<view class="story-block" wx:if="{{profile.storyAchievement}}">
<view class="story-head"><icon name="star" size="28" color="#FFD700" customClass="story-emoji"></icon><text class="story-label">最有成就感的一件事</text></view>
<text class="story-text">{{profile.storyAchievement}}</text>
<view class="skills-quote">
<text class="skills-quote-text">{{profile.skills}}</text>
</view>
<view class="field-divider" wx:if="{{profile.storyAchievement && profile.storyTurning}}"></view>
<view class="story-block" wx:if="{{profile.storyTurning}}">
<view class="story-head"><icon name="refresh-cw" size="28" color="#FFD700" customClass="story-emoji"></icon><text class="story-label">人生的转折点</text></view>
<text class="story-text">{{profile.storyTurning}}</text>
</view>
<view class="mono-divider" wx:if="{{profile.skills && (profile.storyBestMonth || profile.storyAchievement || profile.storyTurning)}}"></view>
<!-- 个人故事 -->
<view class="mono-sec" wx:if="{{profile.storyBestMonth || profile.storyAchievement || profile.storyTurning}}">
<view class="mono-sec-head">
<text class="mono-sec-title">个人故事</text>
</view>
<view class="story" wx:if="{{profile.storyBestMonth}}">
<view class="story-head"><icon name="trophy" size="28" color="#FBBF24" customClass="story-icon"></icon><text class="story-q">最赚钱的一个月</text></view>
<text class="story-a">{{profile.storyBestMonth}}</text>
</view>
<view class="story-gap" wx:if="{{profile.storyBestMonth && (profile.storyAchievement || profile.storyTurning)}}"></view>
<view class="story" wx:if="{{profile.storyAchievement}}">
<view class="story-head"><icon name="star" size="28" color="#FBBF24" customClass="story-icon"></icon><text class="story-q">最有成就感的事</text></view>
<text class="story-a">{{profile.storyAchievement}}</text>
</view>
<view class="story-gap" wx:if="{{profile.storyAchievement && profile.storyTurning}}"></view>
<view class="story" wx:if="{{profile.storyTurning}}">
<view class="story-head"><icon name="refresh-cw" size="28" color="#FBBF24" customClass="story-icon"></icon><text class="story-q">人生的转折点</text></view>
<text class="story-a">{{profile.storyTurning}}</text>
</view>
</view>
<view class="mono-divider" wx:if="{{(profile.storyBestMonth || profile.storyAchievement || profile.storyTurning) && (profile.helpOffer || profile.helpNeed)}}"></view>
<!-- 互助需求 -->
<view class="mono-sec" wx:if="{{profile.helpOffer || profile.helpNeed}}">
<view class="mono-sec-head">
<text class="mono-sec-title">互助需求</text>
</view>
<view class="help-grid">
<view class="help-tile help-give" wx:if="{{profile.helpOffer}}">
<text class="help-tile-tag">我能帮你</text>
<text class="help-tile-txt">{{profile.helpOffer}}</text>
</view>
<view class="help-tile help-need" wx:if="{{profile.helpNeed}}">
<text class="help-tile-tag need">我需要</text>
<text class="help-tile-txt">{{profile.helpNeed}}</text>
</view>
</view>
</view>
<view class="mono-divider" wx:if="{{(profile.helpOffer || profile.helpNeed) && profile.projectIntro}}"></view>
<!-- 项目介绍 -->
<view class="mono-sec" wx:if="{{profile.projectIntro}}">
<view class="mono-sec-head">
<text class="mono-sec-title">项目介绍</text>
</view>
<text class="proj-body">{{profile.projectIntro}}</text>
</view>
</view>
<!-- 互助需求 -->
<view class="section" wx:if="{{profile.helpOffer || profile.helpNeed}}">
<view class="section-head">
<icon name="handshake" size="40" color="#00CED1" customClass="section-icon"></icon>
<text class="section-title">互助需求</text>
</view>
<view class="section-body">
<view class="help-block" wx:if="{{profile.helpOffer}}">
<text class="help-tag help-tag-accent">我能帮你</text>
<text class="help-text">{{profile.helpOffer}}</text>
</view>
<view class="help-block" wx:if="{{profile.helpNeed}}">
<text class="help-tag help-tag-orange">我需要帮助</text>
<text class="help-text">{{profile.helpNeed}}</text>
</view>
</view>
<!-- 空态 -->
<view class="empty-hint" wx:if="{{!profile.industry && !profile.position && !profile.businessScale && !profile.skills && !profile.storyBestMonth && !profile.storyAchievement && !profile.storyTurning && !profile.helpOffer && !profile.helpNeed && !profile.projectIntro}}">
<icon name="edit" size="48" color="#334155"></icon>
<text class="empty-hint-txt">资料尚未完善,点击右上角编辑</text>
</view>
<!-- 项目介绍 -->
<view class="section" wx:if="{{profile.projectIntro}}">
<view class="section-head">
<icon name="rocket" size="40" color="#00CED1" customClass="section-icon"></icon>
<text class="section-title">项目介绍</text>
</view>
<view class="section-body">
<text class="project-text">{{profile.projectIntro}}</text>
</view>
</view>
<view class="bottom-spacer"></view>
<view class="scroll-pad"></view>
</scroll-view>
<!-- 底部按钮 - 设计稿为描边橙色 -->
<view class="bottom-bar">
<view class="vip-btn-outline" bindtap="goToVip">
<!-- 底部按钮 -->
<view class="bottom-bar" wx:if="{{!loading}}">
<view class="vip-btn" bindtap="goToVip">
<text>成为超级个体</text>
<icon name="chevron-right" size="36" color="#00CED1" customClass="vip-btn-arrow"></icon>
<icon name="chevron-right" size="32" color="#0f172a"></icon>
</view>
</view>
</view>

View File

@@ -1,115 +1,183 @@
/* 个人资料展示页 - enhanced_professional_profile 1:1 重构 */
.page { background: #050B14; min-height: 100vh; color: #fff; }
/* 卡若创业派对 - 个人资料展示(与 member-detail 同一视觉语言) */
.page {
background: radial-gradient(120% 80% at 50% -20%, rgba(20, 80, 90, 0.35) 0%, #050B14 45%);
min-height: 100vh;
color: #fff;
}
/* —— 导航 —— */
.nav-bar {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
position: fixed; top: 0; left: 0; right: 0; z-index: 999;
display: flex; align-items: center; justify-content: space-between;
height: 44px; padding: 0 32rpx;
background: rgba(5,11,20,0.9); backdrop-filter: blur(8rpx);
border-bottom: 1rpx solid rgba(255,255,255,0.05);
padding: 0 28rpx; height: 44px;
background: rgba(5, 11, 20, 0.72);
backdrop-filter: blur(20rpx); -webkit-backdrop-filter: blur(20rpx);
border-bottom: 1rpx solid rgba(255, 255, 255, 0.06);
}
.nav-back { padding: 16rpx; margin-left: -8rpx; }
.back-icon { font-size: 40rpx; color: #5EEAD4; }
.nav-title { font-size: 34rpx; font-weight: bold; }
.nav-right { padding: 16rpx; }
.nav-more { font-size: 48rpx; color: #fff; line-height: 1; }
.nav-placeholder { width: 100%; }
.nav-back { width: 72rpx; height: 72rpx; display: flex; align-items: center; justify-content: flex-start; }
.nav-icon { font-size: 44rpx; color: #5eead4; font-weight: 300; }
.nav-title { font-size: 32rpx; font-weight: 600; color: #f8fafc; letter-spacing: 4rpx; }
.nav-edit { width: 72rpx; height: 72rpx; display: flex; align-items: center; justify-content: flex-end; }
.loading { padding: 96rpx; text-align: center; color: #94A3B8; }
.scroll-wrap { box-sizing: border-box; }
.scroll-main { height: calc(100vh - 120rpx); padding: 0 32rpx 32rpx; }
/* 头像区卡片 */
.hero-card {
position: relative; overflow: hidden;
background: #0F1720; border: 1rpx solid rgba(255,255,255,0.08);
border-radius: 32rpx; margin-bottom: 32rpx;
padding: 64rpx 32rpx; display: flex; flex-direction: column; align-items: center;
/* —— 首屏外壳 —— */
.shell {
position: relative; margin: 28rpx 24rpx 0; padding: 40rpx 32rpx 36rpx;
border-radius: 32rpx;
background: linear-gradient(145deg, rgba(22, 36, 48, 0.95) 0%, rgba(12, 20, 32, 0.98) 100%);
border: 1rpx solid rgba(94, 234, 212, 0.12);
box-shadow: 0 24rpx 80rpx rgba(0, 0, 0, 0.45), inset 0 1rpx 0 rgba(255, 255, 255, 0.06);
overflow: hidden;
}
.hero-gradient {
position: absolute; top: 0; left: 0; right: 0; height: 128rpx;
background: linear-gradient(to bottom, rgba(30,58,69,0.3) 0%, transparent 100%);
.shell-glow {
position: absolute; top: -40%; right: -20%; width: 70%; height: 80%;
background: radial-gradient(circle, rgba(45, 212, 191, 0.12) 0%, transparent 70%);
pointer-events: none;
}
.hero-content { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; }
.hero-avatar {
width: 176rpx; height: 176rpx; border-radius: 50%;
overflow: hidden; border: 2rpx solid rgba(255,255,255,0.1);
margin-bottom: 32rpx;
.hero-row { position: relative; z-index: 1; display: flex; align-items: flex-start; gap: 28rpx; }
.avatar-outer { position: relative; width: 168rpx; height: 168rpx; flex-shrink: 0; }
.avatar-wrap {
width: 100%; height: 100%; border-radius: 50%; overflow: hidden;
border: 2rpx solid rgba(255, 255, 255, 0.12);
box-shadow: 0 12rpx 40rpx rgba(0, 0, 0, 0.35);
}
.avatar-img { width: 100%; height: 100%; display: block; }
.avatar-placeholder {
width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;
font-size: 72rpx; font-weight: bold; color: #5EEAD4;
background: rgba(94,234,212,0.2);
.avatar-img { width: 100%; height: 100%; object-fit: cover; }
.avatar-ph {
width: 100%; height: 100%; background: #1a2332;
display: flex; align-items: center; justify-content: center;
font-size: 52rpx; color: #5eead4; font-weight: 700;
}
.hero-name { font-size: 40rpx; font-weight: bold; margin-bottom: 24rpx; }
.hero-tags { display: flex; align-items: center; justify-content: center; gap: 24rpx; }
.tag { padding: 8rpx 24rpx; border-radius: 999rpx; font-size: 24rpx; font-weight: 500; }
.tag-mbti { background: #134E4A; color: #5EEAD4; border: 1rpx solid rgba(94,234,212,0.2); }
.tag-region { display: flex; align-items: center; gap: 8rpx; background: #1F2937; color: #d1d5db; border: 1rpx solid rgba(255,255,255,0.1); }
.tag-region .tag-icon { flex-shrink: 0; }
/* 通用区块 */
.section {
background: #0F1720; border: 1rpx solid rgba(255,255,255,0.08);
border-radius: 32rpx; margin-bottom: 32rpx;
padding: 40rpx; box-shadow: 0 16rpx 32rpx rgba(0,0,0,0.2);
/* 右侧联系方式列 */
.link-column { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 16rpx; }
.link-column-title { font-size: 26rpx; font-weight: 700; color: #f1f5f9; letter-spacing: 2rpx; }
.link-chip {
display: flex; align-items: center; gap: 20rpx;
padding: 22rpx; border-radius: 20rpx;
background: rgba(15, 23, 42, 0.65);
border: 1rpx solid rgba(94, 234, 212, 0.25);
}
.section-head { display: flex; align-items: center; gap: 20rpx; margin-bottom: 40rpx; }
.section-icon { font-size: 40rpx; }
.section-icon-yellow { filter: brightness(1.2); }
.section-title { font-size: 30rpx; font-weight: bold; }
.section-body { }
.field { margin-bottom: 48rpx; }
.field:last-child { margin-bottom: 0; }
.field-label { display: block; font-size: 26rpx; color: #94A3B8; margin-bottom: 16rpx; }
.field-value { font-size: 30rpx; font-weight: 500; color: #fff; line-height: 1.5; }
.field-value.mono { font-family: monospace; letter-spacing: 0.02em; }
.field-value-row { display: flex; align-items: center; gap: 16rpx; }
.field-hint { font-size: 24rpx; color: #5EEAD4; }
.field-divider { height: 1rpx; background: rgba(255,255,255,0.05); margin: 32rpx 0; }
.field-empty { font-size: 26rpx; color: #64748b; }
/* 个人故事 */
.story-block { margin-bottom: 48rpx; }
.story-block:last-child { margin-bottom: 0; }
.story-head { display: flex; align-items: center; gap: 16rpx; margin-bottom: 16rpx; }
.story-emoji { font-size: 32rpx; }
.story-label { font-size: 26rpx; font-weight: 500; color: #94A3B8; }
.story-text { font-size: 28rpx; color: #e5e7eb; line-height: 1.6; display: block; }
/* 互助需求 */
.help-block {
background: #17212F; border: 1rpx solid rgba(255,255,255,0.05);
border-radius: 20rpx; padding: 32rpx; margin-bottom: 24rpx;
.link-chip:active { background: rgba(15, 30, 40, 0.75); }
.link-chip-icon {
width: 64rpx; height: 64rpx; border-radius: 16rpx;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.help-block:last-child { margin-bottom: 0; }
.help-tag {
display: inline-block; font-size: 22rpx; font-weight: 500;
padding: 8rpx 16rpx; border-radius: 8rpx; margin-bottom: 16rpx;
.link-chip-icon-phone { background: rgba(45, 212, 191, 0.12); }
.link-chip-icon-wx { background: rgba(52, 211, 153, 0.12); }
.lc-ic { display: block; }
.link-chip-main { flex: 1; min-width: 0; }
.link-chip-label { display: block; font-size: 20rpx; color: #94a3b8; margin-bottom: 6rpx; }
.link-chip-val { display: block; font-size: 26rpx; font-weight: 600; color: #f8fafc; line-height: 1.35; word-break: break-all; }
.link-chip-val.mono { font-family: ui-monospace, monospace; letter-spacing: 1rpx; }
.link-chip-action { flex-shrink: 0; font-size: 22rpx; font-weight: 600; color: #5eead4; }
.link-empty { padding: 24rpx; border-radius: 20rpx; background: rgba(15, 23, 42, 0.4); border: 1rpx dashed rgba(148, 163, 184, 0.2); }
.link-empty-txt { font-size: 24rpx; color: #64748b; }
.profile-name {
position: relative; z-index: 1; display: block; text-align: center;
margin-top: 36rpx; font-size: 40rpx; font-weight: 700; color: #fff; letter-spacing: 4rpx;
}
.help-tag-accent { background: #112D2A; color: #5EEAD4; }
.help-tag-orange { background: #2D1F0D; color: #F59E0B; }
.help-text { font-size: 26rpx; color: #fff; line-height: 1.6; display: block; }
.profile-tags {
position: relative; z-index: 1;
display: flex; align-items: center; justify-content: center; gap: 20rpx; flex-wrap: wrap;
margin-top: 24rpx;
}
.tag { font-size: 24rpx; font-weight: 500; padding: 10rpx 26rpx; border-radius: 999rpx; }
.tag-mbti { background: rgba(19, 78, 74, 0.6); color: #5eead4; border: 1rpx solid rgba(94, 234, 212, 0.25); }
.tag-region {
background: rgba(30, 41, 59, 0.8); color: #cbd5e1; border: 1rpx solid rgba(255, 255, 255, 0.08);
display: flex; align-items: center; gap: 8rpx;
}
.pin-icon { color: #f87171; font-size: 22rpx; }
.project-text { font-size: 28rpx; color: #e5e7eb; line-height: 1.6; }
/* —— 一体化信息卡 —— */
.mono-card {
margin: 24rpx 24rpx 0; padding: 8rpx 0 32rpx; border-radius: 32rpx;
background: rgba(15, 23, 34, 0.88);
border: 1rpx solid rgba(255, 255, 255, 0.07);
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.25);
}
.mono-sec { padding: 28rpx 32rpx 8rpx; }
.mono-sec-head { margin-bottom: 24rpx; }
.mono-sec-title { font-size: 32rpx; font-weight: 700; color: #f8fafc; }
.mono-divider { height: 1rpx; margin: 8rpx 32rpx; background: linear-gradient(90deg, transparent, rgba(148, 163, 184, 0.15), transparent); }
.bottom-spacer { height: 180rpx; }
.kv { margin-bottom: 28rpx; }
.kv:last-child { margin-bottom: 8rpx; }
.kv-k { display: block; font-size: 22rpx; color: #64748b; margin-bottom: 10rpx; }
.kv-v { font-size: 28rpx; color: #e2e8f0; line-height: 1.65; font-weight: 500; }
/* 底部按钮 - 设计稿:透明背景 + 橙色描边 */
.skills-showcase .skills-quote {
padding: 28rpx 28rpx 28rpx 24rpx; border-radius: 20rpx;
background: linear-gradient(105deg, rgba(45, 212, 191, 0.08) 0%, rgba(15, 23, 42, 0.5) 100%);
border-left: 6rpx solid #2dd4bf;
box-shadow: inset 0 0 0 1rpx rgba(45, 212, 191, 0.12);
}
.skills-quote-text { font-size: 30rpx; color: #f1f5f9; line-height: 1.75; font-weight: 500; }
.story { margin-bottom: 8rpx; }
.story-gap { height: 28rpx; }
.story-head { display: flex; align-items: center; gap: 12rpx; margin-bottom: 12rpx; }
.story-icon { flex-shrink: 0; }
.story-q { font-size: 24rpx; font-weight: 600; color: #94a3b8; }
.story-a { display: block; font-size: 28rpx; color: #e2e8f0; line-height: 1.7; padding-left: 4rpx; }
.help-grid { display: flex; flex-direction: column; gap: 20rpx; }
.help-tile {
padding: 28rpx; border-radius: 22rpx;
background: rgba(23, 33, 47, 0.9); border: 1rpx solid rgba(255, 255, 255, 0.06);
}
.help-tile-tag {
display: inline-block; font-size: 20rpx; font-weight: 700;
padding: 8rpx 18rpx; border-radius: 12rpx; margin-bottom: 16rpx;
}
.help-give .help-tile-tag { color: #5eead4; background: rgba(6, 78, 59, 0.45); border: 1rpx solid rgba(45, 212, 191, 0.2); }
.help-need .help-tile-tag.need { color: #fbbf24; background: rgba(69, 47, 8, 0.45); border: 1rpx solid rgba(251, 191, 36, 0.2); }
.help-tile-txt { font-size: 28rpx; color: #f1f5f9; line-height: 1.65; }
.proj-body { font-size: 28rpx; color: #cbd5e1; line-height: 1.75; padding-bottom: 8rpx; }
/* —— 空态 —— */
.empty-hint {
margin: 48rpx 24rpx; padding: 64rpx 32rpx;
display: flex; flex-direction: column; align-items: center; gap: 24rpx;
border-radius: 28rpx; background: rgba(15, 23, 34, 0.5);
border: 1rpx dashed rgba(148, 163, 184, 0.15);
}
.empty-hint-txt { font-size: 26rpx; color: #64748b; }
.scroll-pad { height: calc(80rpx + env(safe-area-inset-bottom)); }
/* —— 底部 —— */
.bottom-bar {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 50;
padding: 32rpx; padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
background: rgba(5,11,20,0.95); backdrop-filter: blur(8rpx);
border-top: 1rpx solid rgba(255,255,255,0.05);
padding: 20rpx 24rpx; padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
background: rgba(5, 11, 20, 0.92); backdrop-filter: blur(20rpx);
border-top: 1rpx solid rgba(255, 255, 255, 0.06);
}
.vip-btn-outline {
display: flex; align-items: center; justify-content: center; gap: 16rpx;
width: 100%; height: 96rpx;
background: transparent; color: #F59E0B;
border: 2rpx solid rgba(245,158,11,0.3);
border-radius: 999rpx; font-size: 30rpx; font-weight: 500;
.vip-btn {
display: flex; align-items: center; justify-content: center; gap: 12rpx;
width: 100%; height: 88rpx; border-radius: 999rpx; border: none;
background: linear-gradient(135deg, #5eead4 0%, #2dd4bf 50%, #14b8a6 100%);
color: #0f172a; font-size: 28rpx; font-weight: 700;
box-shadow: 0 8rpx 28rpx rgba(45, 212, 191, 0.35);
}
.vip-btn-arrow { font-size: 36rpx; }
.vip-btn:active { opacity: 0.85; }
/* —— 加载态 —— */
.state-wrap {
display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 60vh; gap: 24rpx;
}
.state-txt { font-size: 28rpx; color: #64748b; }
.loading-dot {
width: 56rpx; height: 56rpx; border-radius: 50%;
border: 4rpx solid rgba(94, 234, 212, 0.2); border-top-color: #5eead4;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

View File

@@ -23,20 +23,23 @@ Page({
if (userId) {
const res = await app.request(`/api/miniprogram/orders?userId=${userId}`)
if (res && res.success && res.data) {
const orders = (res.data || []).map(item => ({
const raw = (res.data || []).map(item => ({
id: item.id || item.order_sn,
sectionId: item.product_id || item.section_id,
sectionMid: item.section_mid ?? item.mid ?? 0,
title: item.product_name || `章节 ${item.product_id || ''}`,
amount: item.amount || 0,
status: item.status || 'completed',
createTime: item.created_at ? new Date(item.created_at).toLocaleDateString() : '--'
createTime: item.created_at ? new Date(item.created_at).toLocaleDateString() : '--',
_sortMs: new Date(item.created_at || item.pay_time || 0).getTime() || 0
}))
raw.sort((a, b) => b._sortMs - a._sortMs)
const orders = raw.map(({ _sortMs, ...rest }) => rest)
this.setData({ orders })
return
}
}
const purchasedSections = app.globalData.purchasedSections || []
const purchasedSections = [...(app.globalData.purchasedSections || [])].reverse()
const orders = purchasedSections.map((id, index) => ({
id: `order_${index}`,
sectionId: id,
@@ -49,7 +52,7 @@ Page({
this.setData({ orders })
} catch (e) {
console.error('加载订单失败:', e)
const purchasedSections = app.globalData.purchasedSections || []
const purchasedSections = [...(app.globalData.purchasedSections || [])].reverse()
this.setData({
orders: purchasedSections.map((id, i) => ({
id: `order_${i}`, sectionId: id, sectionMid: 0, title: `章节 ${id}`, amount: 1, status: 'completed',
@@ -61,6 +64,14 @@ Page({
}
},
goToRead(e) {
const id = e.currentTarget.dataset.id
const mid = e.currentTarget.dataset.mid
if (!id) return
const q = mid ? `mid=${mid}` : `id=${id}`
wx.navigateTo({ url: `/pages/read/read?${q}` })
},
goBack() { getApp().goBackOrToHome() },
onShareAppMessage() {

View File

@@ -15,9 +15,12 @@
</view>
<view class="orders-list" wx:elif="{{orders.length > 0}}">
<view class="order-item" wx:for="{{orders}}" wx:key="id">
<view class="order-item" wx:for="{{orders}}" wx:key="id" bindtap="goToRead" data-id="{{item.sectionId}}" data-mid="{{item.sectionMid}}">
<view class="order-info">
<text class="order-title">{{item.title}}</text>
<view class="order-title-row">
<text class="order-unlock-icon">🔓</text>
<text class="order-title">{{item.title}}</text>
</view>
<text class="order-time">{{item.createTime}}</text>
</view>
<view class="order-right">

View File

@@ -9,8 +9,11 @@
@keyframes skeleton { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
.orders-list { display: flex; flex-direction: column; gap: 16rpx; }
.order-item { display: flex; align-items: center; justify-content: space-between; padding: 24rpx; background: #1c1c1e; border-radius: 24rpx; }
.order-info { flex: 1; }
.order-title { font-size: 28rpx; color: #fff; display: block; margin-bottom: 8rpx; }
.order-item:active { opacity: 0.92; }
.order-info { flex: 1; min-width: 0; }
.order-title-row { display: flex; align-items: flex-start; gap: 12rpx; margin-bottom: 8rpx; }
.order-unlock-icon { font-size: 26rpx; line-height: 1.35; opacity: 0.55; flex-shrink: 0; }
.order-title { font-size: 28rpx; color: #fff; flex: 1; min-width: 0; }
.order-time { font-size: 22rpx; color: rgba(255,255,255,0.4); }
.order-right { text-align: right; }
.order-amount { font-size: 28rpx; font-weight: 600; color: #00CED1; display: block; margin-bottom: 4rpx; }

View File

@@ -13,14 +13,44 @@
* - contentSegments 解析每行mention 高亮可点;点击→确认→登录/资料校验→POST /api/miniprogram/ckb/lead
*/
import accessManager from '../../utils/chapterAccessManager'
import readingTracker from '../../utils/readingTracker'
const accessManager = require('../../utils/chapterAccessManager')
const readingTracker = require('../../utils/readingTracker')
const { parseScene } = require('../../utils/scene.js')
const contentParser = require('../../utils/contentParser.js')
const { trackClick } = require('../../utils/trackClick')
const app = getApp()
/** 阅读页解析正文用:人物字典 + #标签(与 /config/read-extras 一致) */
function getContentParseConfig() {
const g = getApp().globalData || {}
const raw = Array.isArray(g.mentionPersons) ? g.mentionPersons : []
const persons = raw.map((p) => ({
personId: p.personId || '',
token: p.token || '',
name: (p.name || '').trim(),
label: (p.label || '').trim(),
aliases: p.aliases != null ? String(p.aliases) : '',
}))
const linkTags = Array.isArray(g.linkTagsConfig) ? g.linkTagsConfig : []
return { persons, linkTags }
}
/** 补全 mentionDisplay避免旧数据无字段昵称去空白防「@ 名」 */
function normalizeMentionSegments(segments) {
if (!Array.isArray(segments)) return []
return segments.map((row) => {
if (!Array.isArray(row)) return row
return row.map((seg) => {
if (!seg || seg.type !== 'mention') return seg
const nick = String(seg.nickname || '')
.replace(/^[\s\u00a0\u200b\u3000]+/g, '')
.replace(/[\s\u00a0\u200b\u3000]+$/g, '')
return { ...seg, nickname: nick, mentionDisplay: '@' + nick }
})
})
}
Page({
data: {
// 系统信息
@@ -309,6 +339,8 @@ Page({
async loadContent(id, accessState, prefetchedChapter) {
const cacheKey = `chapter_${id}`
try {
await app.getReadExtras()
const parseCfg = getContentParseConfig()
const sectionPrice = this.data.sectionPrice ?? 1
let res = prefetchedChapter
if (!res || !res.content) {
@@ -325,13 +357,13 @@ Page({
// 已解锁用 data.content完整内容未解锁用 content预览先 determineAccessState 再 loadContent 保证顺序正确
const displayContent = accessManager.canAccessFullContent(accessState) ? (res.data?.content ?? res.content) : res.content
if (res && displayContent) {
const { lines, segments } = contentParser.parseContent(displayContent)
const { lines, segments } = contentParser.parseContent(displayContent, parseCfg)
// 预览内容由后端统一截取比例,这里展示全部预览内容
const previewCount = lines.length
const updates = {
content: displayContent,
contentParagraphs: lines,
contentSegments: segments,
contentSegments: normalizeMentionSegments(segments),
previewParagraphs: lines.slice(0, previewCount),
partTitle: res.partTitle || '',
chapterTitle: res.chapterTitle || ''
@@ -349,13 +381,14 @@ Page({
try {
const cached = wx.getStorageSync(cacheKey)
if (cached && cached.content) {
const { lines, segments } = contentParser.parseContent(cached.content)
await app.getReadExtras()
const { lines, segments } = contentParser.parseContent(cached.content, getContentParseConfig())
// 预览内容由后端统一截取比例,这里展示全部预览内容
const previewCount = lines.length
this.setData({
content: cached.content,
contentParagraphs: lines,
contentSegments: segments,
contentSegments: normalizeMentionSegments(segments),
previewParagraphs: lines.slice(0, previewCount),
partTitle: cached.partTitle || '',
chapterTitle: cached.chapterTitle || ''
@@ -454,8 +487,9 @@ Page({
},
// 设置章节内容(兼容纯文本/Markdown 与 TipTap HTML
setChapterContent(res) {
const { lines, segments } = contentParser.parseContent(res.content)
async setChapterContent(res) {
await app.getReadExtras()
const { lines, segments } = contentParser.parseContent(res.content, getContentParseConfig())
// 预览内容由后端统一截取比例,这里展示全部预览内容
const previewCount = lines.length
const sectionPrice = this.data.sectionPrice ?? 1
@@ -472,7 +506,7 @@ Page({
content: res.content,
previewContent: lines.slice(0, previewCount).join('\n'),
contentParagraphs: lines,
contentSegments: segments,
contentSegments: normalizeMentionSegments(segments),
previewParagraphs: lines.slice(0, previewCount),
partTitle: res.partTitle || '',
// 导航栏、分享等使用的文章标题,同样统一为 sectionTitle
@@ -498,7 +532,7 @@ Page({
if (currentRetry >= maxRetries) {
this.setData({
contentParagraphs: ['内容加载失败', '请检查网络连接后下拉刷新重试'],
contentSegments: contentParser.parseContent('内容加载失败\n请检查网络连接后下拉刷新重试').segments,
contentSegments: normalizeMentionSegments(contentParser.parseContent('内容加载失败\n请检查网络连接后下拉刷新重试').segments),
previewParagraphs: ['内容加载失败']
})
return
@@ -508,7 +542,7 @@ Page({
try {
const res = await this.fetchChapterWithTimeout(id, 8000)
if (res && res.content) {
this.setChapterContent(res)
await this.setChapterContent(res)
wx.setStorageSync(`chapter_${id}`, res)
console.log('[Read] 重试成功:', id, '第', currentRetry + 1, '次')
return
@@ -674,12 +708,6 @@ Page({
})
return
}
// 2 分钟内只能点一次(与后端限频一致,与首页链接卡若共用)
const leadLastTs = wx.getStorageSync('lead_last_submit_ts') || 0
if (Date.now() - leadLastTs < 2 * 60 * 1000) {
wx.showToast({ title: '操作太频繁请2分钟后再试', icon: 'none' })
return
}
wx.showLoading({ title: '提交中...', mask: true })
try {
const res = await app.request({
@@ -875,10 +903,8 @@ Page({
#创业派对 #私域运营 #商业案例`
wx.setClipboardData({
data: shareText,
success: () => {
wx.showToast({ title: '文案已复制', icon: 'success' })
}
data: shareText
// 不额外 showToast系统已有「内容已复制」避免与自定义文案叠两层
})
},
@@ -929,12 +955,17 @@ Page({
wx.setClipboardData({
data: copyText,
success: () => {
wx.showModal({
title: '文案已复制',
content: '请点击右上角「···」菜单,选择「分享到朋友圈」即可发布',
showCancel: false,
confirmText: '知道了'
})
// 系统会在 setClipboardData 成功后自动 toast「内容已复制」与下方引导弹窗重复先关掉再弹窗
wx.hideToast()
setTimeout(() => {
wx.hideToast()
wx.showModal({
title: '分享到朋友圈',
content: '文案已复制。\n\n请点击右上角「···」菜单选择「分享到朋友圈」即可发布。',
showCancel: false,
confirmText: '知道了'
})
}, 120)
},
fail: () => {
wx.showToast({ title: '复制失败,请手动复制', icon: 'none' })
@@ -1093,7 +1124,28 @@ Page({
async processPayment(type, sectionId, amount) {
console.log('[Pay] processPayment开始:', { type, sectionId, amount })
// 检查金额是否有效
const userInfo = app.globalData.userInfo
if (userInfo?.id) {
const avatar = userInfo.avatarUrl || ''
const nickname = userInfo.nickname || userInfo.nickName || ''
const needProfile = !avatar || avatar.includes('default') || avatar.includes('132') || !nickname || nickname === '微信用户'
if (needProfile) {
const res = await new Promise(resolve => {
wx.showModal({
title: '完善资料',
content: '购买前请先完善头像和昵称',
confirmText: '去完善',
cancelText: '稍后',
success: resolve
})
})
if (res.confirm) {
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
return
}
}
}
if (!amount || amount <= 0) {
console.error('[Pay] 金额无效:', amount)
wx.showToast({ title: '价格信息错误', icon: 'none' })
@@ -1240,13 +1292,13 @@ Page({
// 支付接口失败时,显示客服联系方式
wx.showModal({
title: '支付通道维护中',
content: '微信支付正在审核中,请添加客服微信(28533368)手动购买,感谢理解!',
content: '微信支付正在审核中,请添加客服微信(' + (app.globalData.serviceWechat || '28533368') + ')手动购买,感谢理解!',
confirmText: '复制微信号',
cancelText: '稍后再说',
success: (res) => {
if (res.confirm) {
wx.setClipboardData({
data: '28533368',
data: app.globalData.serviceWechat || '28533368',
success: () => {
wx.showToast({ title: '微信号已复制', icon: 'success' })
}
@@ -1288,13 +1340,13 @@ Page({
// 支付失败,可能是参数错误或权限问题
wx.showModal({
title: '支付失败',
content: '微信支付暂不可用,请添加客服微信(28533368)手动购买',
content: '微信支付暂不可用,请添加客服微信(' + (app.globalData.serviceWechat || '28533368') + ')手动购买',
confirmText: '复制微信号',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
wx.setClipboardData({
data: '28533368',
data: app.globalData.serviceWechat || '28533368',
success: () => wx.showToast({ title: '微信号已复制', icon: 'success' })
})
}
@@ -1447,18 +1499,22 @@ Page({
wx.navigateTo({ url: '/pages/referral/referral' })
},
// 生成海报
// 生成海报Canvas 2D API
async generatePoster() {
wx.showLoading({ title: '生成中...' })
this.setData({ showPosterModal: true, isGeneratingPoster: true })
await new Promise((resolve) => {
this.setData({ showPosterModal: true, isGeneratingPoster: true }, () => resolve())
})
await new Promise((resolve) => {
if (typeof wx.nextTick === 'function') wx.nextTick(resolve)
else setTimeout(resolve, 50)
})
try {
const ctx = wx.createCanvasContext('posterCanvas', this)
const { section, contentParagraphs, sectionId, sectionMid } = this.data
const userInfo = app.globalData.userInfo
const userId = userInfo?.id || ''
// 获取小程序码(带推荐人参数,优先 mid 与新链接一致)
let qrcodeImage = null
try {
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
@@ -1467,109 +1523,102 @@ Page({
method: 'POST',
data: { scene, page: 'pages/read/read', width: 280 }
})
if (qrRes.success && qrRes.image) {
qrcodeImage = qrRes.image
if (qrRes.success && qrRes.image) qrcodeImage = qrRes.image
} catch (_) {}
const canvasNode = await new Promise((resolve, reject) => {
wx.createSelectorQuery().in(this)
.select('#posterCanvas')
.fields({ node: true, size: true })
.exec(res => {
if (res && res[0] && res[0].node) resolve(res[0])
else reject(new Error('canvas node not found'))
})
})
const canvas = canvasNode.node
const ctx = canvas.getContext('2d')
let dpr = 2
try {
if (typeof wx.getWindowInfo === 'function') {
dpr = wx.getWindowInfo().pixelRatio || 2
} else if (wx.getSystemInfoSync) {
dpr = wx.getSystemInfoSync().pixelRatio || 2
}
} catch (e) {
console.log('[Poster] 获取小程序码失败,使用占位符')
} catch (_) {
dpr = 2
}
// 海报尺寸 300x450
const width = 300
const height = 450
// 背景渐变
canvas.width = width * dpr
canvas.height = height * dpr
ctx.scale(dpr, dpr)
const grd = ctx.createLinearGradient(0, 0, 0, height)
grd.addColorStop(0, '#1a1a2e')
grd.addColorStop(1, '#16213e')
ctx.setFillStyle(grd)
ctx.fillStyle = grd
ctx.fillRect(0, 0, width, height)
// 顶部装饰条
ctx.setFillStyle('#00CED1')
ctx.fillStyle = '#00CED1'
ctx.fillRect(0, 0, width, 4)
// 标题区域
ctx.setFillStyle('#ffffff')
ctx.setFontSize(14)
ctx.fillStyle = '#ffffff'
ctx.font = '14px sans-serif'
ctx.fillText('📚 卡若创业派对', 20, 35)
// 章节标题
ctx.setFontSize(18)
ctx.setFillStyle('#ffffff')
ctx.font = '18px sans-serif'
ctx.fillStyle = '#ffffff'
const title = section?.title || '精彩内容'
const titleLines = this.wrapText(ctx, title, width - 40, 18)
const titleLines = this.wrapText2d(ctx, title, width - 40)
let y = 70
titleLines.forEach(line => {
ctx.fillText(line, 20, y)
y += 26
})
// 分隔线
ctx.setStrokeStyle('rgba(255,255,255,0.1)')
titleLines.forEach(line => { ctx.fillText(line, 20, y); y += 26 })
ctx.strokeStyle = 'rgba(255,255,255,0.1)'
ctx.beginPath()
ctx.moveTo(20, y + 10)
ctx.lineTo(width - 20, y + 10)
ctx.stroke()
// 内容摘要
ctx.setFontSize(12)
ctx.setFillStyle('rgba(255,255,255,0.8)')
ctx.font = '12px sans-serif'
ctx.fillStyle = 'rgba(255,255,255,0.8)'
y += 30
const summary = contentParagraphs.slice(0, 3).join(' ').slice(0, 150) + '...'
const summaryLines = this.wrapText(ctx, summary, width - 40, 12)
summaryLines.slice(0, 6).forEach(line => {
ctx.fillText(line, 20, y)
y += 20
})
// 底部区域背景
ctx.setFillStyle('rgba(0,206,209,0.1)')
const summaryLines = this.wrapText2d(ctx, summary, width - 40)
summaryLines.slice(0, 6).forEach(line => { ctx.fillText(line, 20, y); y += 20 })
ctx.fillStyle = 'rgba(0,206,209,0.1)'
ctx.fillRect(0, height - 100, width, 100)
// 左侧提示文字
ctx.setFillStyle('#ffffff')
ctx.setFontSize(13)
ctx.fillStyle = '#ffffff'
ctx.font = '13px sans-serif'
ctx.fillText('长按识别小程序码', 20, height - 60)
ctx.setFillStyle('rgba(255,255,255,0.6)')
ctx.setFontSize(11)
ctx.fillStyle = 'rgba(255,255,255,0.6)'
ctx.font = '11px sans-serif'
ctx.fillText('长按小程序码阅读全文', 20, height - 38)
// 绘制小程序码或占位符
const drawQRCode = () => {
return new Promise((resolve) => {
if (qrcodeImage) {
// 下载base64图片并绘制
const fs = wx.getFileSystemManager()
const filePath = `${wx.env.USER_DATA_PATH}/qrcode_${Date.now()}.png`
const base64Data = qrcodeImage.replace(/^data:image\/\w+;base64,/, '')
fs.writeFile({
filePath,
data: base64Data,
encoding: 'base64',
success: () => {
ctx.drawImage(filePath, width - 85, height - 85, 70, 70)
resolve()
},
fail: () => {
this.drawQRPlaceholder(ctx, width, height)
resolve()
}
})
} else {
this.drawQRPlaceholder(ctx, width, height)
resolve()
}
})
if (qrcodeImage) {
try {
const fs = wx.getFileSystemManager()
const filePath = `${wx.env.USER_DATA_PATH}/qrcode_${Date.now()}.png`
const base64Data = qrcodeImage.replace(/^data:image\/\w+;base64,/, '')
fs.writeFileSync(filePath, base64Data, 'base64')
const img = canvas.createImage()
await new Promise((resolve, reject) => {
img.onload = resolve
img.onerror = reject
img.src = filePath
})
ctx.drawImage(img, width - 85, height - 85, 70, 70)
} catch (_) {
this.drawQRPlaceholder2d(ctx, width, height)
}
} else {
this.drawQRPlaceholder2d(ctx, width, height)
}
await drawQRCode()
ctx.draw(true, () => {
wx.hideLoading()
this.setData({ isGeneratingPoster: false })
})
wx.hideLoading()
this.setData({ isGeneratingPoster: false })
} catch (e) {
console.error('生成海报失败:', e)
wx.hideLoading()
@@ -1577,21 +1626,19 @@ Page({
this.setData({ showPosterModal: false, isGeneratingPoster: false })
}
},
// 绘制小程序码占位符
drawQRPlaceholder(ctx, width, height) {
ctx.setFillStyle('#ffffff')
drawQRPlaceholder2d(ctx, width, height) {
ctx.fillStyle = '#ffffff'
ctx.beginPath()
ctx.arc(width - 50, height - 50, 35, 0, Math.PI * 2)
ctx.fill()
ctx.setFillStyle('#00CED1')
ctx.setFontSize(9)
ctx.fillStyle = '#00CED1'
ctx.font = '9px sans-serif'
ctx.fillText('扫码', width - 57, height - 52)
ctx.fillText('阅读', width - 57, height - 40)
},
// 文字换行处理
wrapText(ctx, text, maxWidth, fontSize) {
wrapText2d(ctx, text, maxWidth) {
const lines = []
let line = ''
for (let i = 0; i < text.length; i++) {
@@ -1613,39 +1660,47 @@ Page({
this.setData({ showPosterModal: false })
},
// 保存海报到相册
// 保存海报到相册Canvas 2D
savePoster() {
wx.canvasToTempFilePath({
canvasId: 'posterCanvas',
success: (res) => {
wx.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: () => {
wx.showToast({ title: '已保存到相册', icon: 'success' })
this.setData({ showPosterModal: false })
},
fail: (err) => {
if (err.errMsg.includes('auth deny')) {
wx.showModal({
title: '提示',
content: '需要相册权限才能保存海报',
confirmText: '去设置',
success: (res) => {
if (res.confirm) {
wx.openSetting()
}
wx.createSelectorQuery().in(this)
.select('#posterCanvas')
.fields({ node: true, size: true })
.exec(res => {
if (!res || !res[0] || !res[0].node) {
wx.showToast({ title: '保存失败', icon: 'none' })
return
}
const canvas = res[0].node
wx.canvasToTempFilePath({
canvas,
success: (r2) => {
wx.saveImageToPhotosAlbum({
filePath: r2.tempFilePath,
success: () => {
wx.showToast({ title: '已保存到相册', icon: 'success' })
this.setData({ showPosterModal: false })
},
fail: (err) => {
if (err.errMsg.includes('auth deny')) {
wx.showModal({
title: '提示',
content: '需要相册权限才能保存海报',
confirmText: '去设置',
success: (m) => {
if (m.confirm) wx.openSetting()
}
})
} else {
wx.showToast({ title: '保存失败', icon: 'none' })
}
})
} else {
wx.showToast({ title: '保存失败', icon: 'none' })
}
}
})
},
fail: () => {
wx.showToast({ title: '生成图片失败', icon: 'none' })
}
})
},
fail: () => {
wx.showToast({ title: '生成图片失败', icon: 'none' })
}
}, this)
}, this)
})
},
// 阻止冒泡

View File

@@ -54,11 +54,9 @@
<!-- 完整内容 - 免费或已购买(支持 @ mention / #linkTag / 图片) -->
<view class="article" wx:if="{{accessState === 'free' || accessState === 'unlocked_purchased'}}">
<view class="paragraph" wx:for="{{contentSegments}}" wx:key="index">
<text user-select wx:if="{{!(item.length === 1 && item[0].type === 'image')}}"><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><text wx:if="{{seg.type === 'text'}}">{{seg.text}}</text><text wx:elif="{{seg.type === 'mention'}}" class="mention" bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">{{seg.mentionDisplay}}</text><text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}" data-app-id="{{seg.appId}}" data-mp-key="{{seg.mpKey}}">#{{seg.label}}</text></block></text>
<block wx:for="{{item}}" wx:key="index" wx:for-item="seg">
<text wx:if="{{seg.type === 'text'}}" user-select>{{seg.text}}</text>
<text wx:elif="{{seg.type === 'mention'}}" class="mention" user-select bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">@{{seg.nickname}}</text>
<text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" user-select bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}" data-app-id="{{seg.appId}}" data-mp-key="{{seg.mpKey}}">#{{seg.label}}</text>
<image wx:elif="{{seg.type === 'image'}}" class="content-image" src="{{seg.src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{seg.src}}"></image>
<image wx:if="{{seg.type === 'image'}}" class="content-image" src="{{seg.src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{seg.src}}"></image>
</block>
</view>
@@ -124,15 +122,23 @@
<!-- 渐变遮罩 -->
<view class="fade-mask"></view>
<!-- 付费墙 - 未登录 -->
<!-- 付费墙 - 未登录:显示购买按钮(朋友圈/分享场景) -->
<view class="paywall">
<view class="paywall-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
<text class="paywall-title">登录后继续阅读</text>
<text class="paywall-desc">已阅读50%,登录后查看完整内容</text>
<text class="paywall-title">解锁完整内容</text>
<text class="paywall-desc">已预览部分内容,登录并购买后阅读全文</text>
<view class="login-btn" bindtap="showLoginModal">
<text class="login-btn-text">手机号一键登录</text>
<view class="purchase-options" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
<text class="btn-label">购买本章</text>
<text class="btn-price brand-color">¥{{section && section.price != null ? section.price : sectionPrice}}</text>
</view>
</view>
<view class="login-btn" bindtap="showLoginModal" style="margin-top:12px">
<text class="login-btn-text">手机号登录后购买</text>
</view>
<text class="paywall-tip" wx:if="{{!auditMode}}">分享给好友一起学习,还能赚取佣金</text>
</view>
<!-- 章节导航 -->
@@ -274,7 +280,7 @@
<!-- 海报预览 -->
<view class="poster-preview">
<canvas canvas-id="posterCanvas" class="poster-canvas" style="width: 300px; height: 450px;"></canvas>
<canvas type="2d" id="posterCanvas" class="poster-canvas" style="width: 300px; height: 450px;"></canvas>
</view>
<view class="poster-actions">
@@ -353,6 +359,6 @@
<!-- 右下角悬浮按钮 - 分享到朋友圈 -->
<view class="fab-share" bindtap="shareToMoments">
<icon name="globe" size="40" color="#ffffff" customClass="fab-moments-icon"></icon>
<icon name="share" size="44" color="#0f172a" customClass="fab-share-icon"></icon>
</view>
</view>

View File

@@ -1287,7 +1287,7 @@
display: block;
}
.fab-moments-icon {
.fab-share-icon {
font-size: 44rpx;
line-height: 1;
}

View File

@@ -63,8 +63,7 @@ Page({
posterReferralLink: '',
posterNickname: '',
posterNicknameInitial: '',
posterCaseCount: 62,
posterCaseCount: 62
},
onLoad() {
@@ -94,28 +93,14 @@ Page({
// 生成邀请码
const referralCode = userInfo.referralCode || 'SOUL' + (userInfo.id || Date.now().toString(36)).toUpperCase().slice(-6)
console.log('[Referral] 开始加载分销数据userId:', userInfo.id)
// 从API获取真实数据
let realData = null
try {
// app.request 第一个参数是 URL 字符串(会自动拼接 baseUrl
const res = await app.request('/api/miniprogram/referral/data?userId=' + userInfo.id)
console.log('[Referral] API返回:', JSON.stringify(res).substring(0, 200))
if (res && res.success && res.data) {
realData = res.data
console.log('[Referral] ✅ 获取推广数据成功')
console.log('[Referral] - bindingCount:', realData.bindingCount)
console.log('[Referral] - paidCount:', realData.paidCount)
console.log('[Referral] - earnings:', realData.earnings)
console.log('[Referral] - expiringCount:', realData.stats?.expiringCount)
} else {
console.log('[Referral] ❌ API返回格式错误:', res?.error || 'unknown')
}
} catch (e) {
console.log('[Referral] API调用失败:', e.message || e)
console.log('[Referral] 错误详情:', e)
console.error('[Referral] API调用失败:', e.message || e)
}
// 使用真实数据或默认值
@@ -123,14 +108,10 @@ Page({
let convertedBindings = realData?.convertedUsers || []
let expiredBindings = realData?.expiredUsers || []
console.log('[Referral] activeBindings:', activeBindings.length)
console.log('[Referral] convertedBindings:', convertedBindings.length)
console.log('[Referral] expiredBindings:', expiredBindings.length)
// 计算即将过期的数量7天内
const expiringCount = realData?.stats?.expiringCount || activeBindings.filter(b => b.daysRemaining <= 7 && b.daysRemaining > 0).length
console.log('[Referral] expiringCount:', expiringCount)
// 计算各类统计
const bindingCount = realData?.bindingCount || activeBindings.length
@@ -153,7 +134,6 @@ Page({
purchaseCount: user.purchaseCount || 0,
conversionDate: user.conversionDate ? this.formatDate(user.conversionDate) : '--'
}
console.log('[Referral] 格式化用户:', formatted.nickname, formatted.status, formatted.daysRemaining + '天')
return formatted
}
@@ -169,15 +149,6 @@ Page({
const availableEarningsNum = Math.max(0, totalCommissionNum - withdrawnNum - pendingWithdrawNum)
const minWithdrawAmount = realData?.minWithdrawAmount || 10
console.log('=== [Referral] 收益计算(完整版)===')
console.log('累计佣金 (totalCommission):', totalCommissionNum)
console.log('已提现金额 (withdrawnEarnings):', withdrawnNum)
console.log('待审核金额 (pendingWithdrawAmount):', pendingWithdrawNum)
console.log('可提现金额 = 累计 - 已提现 - 待审核 =', totalCommissionNum, '-', withdrawnNum, '-', pendingWithdrawNum, '=', availableEarningsNum)
console.log('最低提现金额 (minWithdrawAmount):', minWithdrawAmount)
console.log('按钮判断:', availableEarningsNum, '>=', minWithdrawAmount, '=', availableEarningsNum >= minWithdrawAmount)
console.log('✅ 按钮应该:', availableEarningsNum >= minWithdrawAmount ? '🟢 启用(绿色)' : '⚫ 禁用(灰色)')
const hasWechatId = !!(userInfo?.wechat || userInfo?.wechatId || wx.getStorageSync('user_wechat'))
this.setData({
isLoggedIn: true,
@@ -234,20 +205,6 @@ Page({
})
console.log('[Referral] ✅ 数据设置完成')
console.log('[Referral] - 绑定中:', this.data.bindingCount)
console.log('[Referral] - 即将过期:', this.data.expiringCount)
console.log('[Referral] - 收益:', this.data.earnings)
console.log('=== [Referral] 按钮状态验证 ===')
console.log('累计佣金 (totalCommission):', this.data.totalCommission)
console.log('待审核金额 (pendingWithdrawAmount):', this.data.pendingWithdrawAmount)
console.log('可提现金额 (availableEarnings 显示):', this.data.availableEarnings)
console.log('可提现金额 (availableEarningsNum 判断):', this.data.availableEarningsNum, typeof this.data.availableEarningsNum)
console.log('最低提现金额 (minWithdrawAmount):', this.data.minWithdrawAmount, typeof this.data.minWithdrawAmount)
console.log('按钮启用条件:', this.data.availableEarningsNum, '>=', this.data.minWithdrawAmount, '=', this.data.availableEarningsNum >= this.data.minWithdrawAmount)
console.log('✅ 最终结果: 按钮应该', this.data.availableEarningsNum >= this.data.minWithdrawAmount ? '🟢 启用' : '⚫ 禁用')
// 隐藏加载提示
wx.hideLoading()
} else {

View File

@@ -3,20 +3,13 @@
* 账号绑定功能
*/
const app = getApp()
const { toAvatarPath } = require('../../utils/util.js')
Page({
data: {
statusBarHeight: 44,
isLoggedIn: false,
userInfo: null,
version: '1.0.0',
isDevMode: false, // 是否开发版(用于显示切换账号入口)
// 切换账号(开发)
showSwitchAccountModal: false,
switchAccountUserId: '',
switchAccountLoading: false,
version: '',
// 绑定信息
phoneNumber: '',
@@ -38,16 +31,27 @@ Page({
wx.showShareMenu({ withShareTimeline: true })
const accountInfo = wx.getAccountInfoSync ? wx.getAccountInfoSync() : null
const envVersion = accountInfo?.miniProgram?.envVersion || ''
const wxPkgVersion = (accountInfo?.miniProgram?.version || '').trim()
const displayVersion =
wxPkgVersion ||
(app.globalData.appDisplayVersion || '1.7.1')
this.setData({
statusBarHeight: app.globalData.statusBarHeight,
isLoggedIn: app.globalData.isLoggedIn,
userInfo: app.globalData.userInfo,
isDevMode: envVersion === 'develop'
isDevMode: envVersion === 'develop',
version: displayVersion
})
this.loadBindingInfo()
},
onShow() {
const accountInfo = wx.getAccountInfoSync ? wx.getAccountInfoSync() : null
const wxPkgVersion = (accountInfo?.miniProgram?.version || '').trim()
const displayVersion =
wxPkgVersion ||
(app.globalData.appDisplayVersion || '1.7.1')
this.setData({ version: displayVersion })
this.loadBindingInfo()
},
@@ -247,92 +251,6 @@ Page({
}
},
// 获取微信头像(新版授权)
async getWechatAvatar() {
try {
const res = await wx.getUserProfile({
desc: '用于完善会员资料'
})
if (res.userInfo) {
const { nickName, avatarUrl: tempAvatarUrl } = res.userInfo
wx.showLoading({ title: '上传中...', mask: true })
// 1. 先上传图片到服务器
console.log('[Settings] 开始上传头像:', tempAvatarUrl)
const uploadRes = await new Promise((resolve, reject) => {
wx.uploadFile({
url: app.globalData.baseUrl + '/api/miniprogram/upload',
filePath: tempAvatarUrl,
name: 'file',
formData: {
folder: 'avatars'
},
success: (uploadResult) => {
try {
const data = JSON.parse(uploadResult.data)
if (data.success) {
resolve(data)
} else {
reject(new Error(data.error || '上传失败'))
}
} catch (err) {
reject(new Error('解析响应失败'))
}
},
fail: (err) => {
reject(err)
}
})
})
// 2. 获取上传后的完整URL显示用保存时只传路径
let avatarUrl = uploadRes.data?.url || uploadRes.url
if (avatarUrl && !avatarUrl.startsWith('http')) {
avatarUrl = app.globalData.baseUrl + avatarUrl
}
console.log('[Settings] 头像上传成功:', avatarUrl)
// 3. 更新本地
this.setData({
userInfo: {
...this.data.userInfo,
nickname: nickName,
avatar: avatarUrl
}
})
// 4. 同步到服务器数据库(只保存路径,不含域名)
const userId = app.globalData.userInfo?.id
if (userId) {
await app.request('/api/miniprogram/user/profile', {
method: 'POST',
data: { userId, nickname: nickName, avatar: toAvatarPath(avatarUrl) }
})
}
// 5. 更新全局
if (app.globalData.userInfo) {
app.globalData.userInfo.nickname = nickName
app.globalData.userInfo.avatar = avatarUrl
wx.setStorageSync('userInfo', app.globalData.userInfo)
}
wx.hideLoading()
wx.showToast({ title: '头像更新成功', icon: 'success' })
}
} catch (e) {
wx.hideLoading()
console.error('[Settings] 获取头像失败:', e)
wx.showToast({
title: e.message || '获取头像失败',
icon: 'none'
})
}
},
// 微信隐私协议同意getPhoneNumber 需先同意)
onAgreePrivacyForPhone() {
if (app._privacyResolve) {
@@ -402,71 +320,6 @@ Page({
this.setData({ showBindModal: false })
},
// 跳转账户密码登录页(开发)
goToDevLogin() {
wx.navigateTo({ url: '/pages/dev-login/dev-login' })
},
// 打开切换账号弹窗(开发)
openSwitchAccountModal() {
this.setData({
showSwitchAccountModal: true,
switchAccountUserId: app.globalData.userInfo?.id || ''
})
},
// 关闭切换账号弹窗
closeSwitchAccountModal() {
if (this.data.switchAccountLoading) return
this.setData({ showSwitchAccountModal: false, switchAccountUserId: '' })
},
// 切换账号 userId 输入
onSwitchAccountUserIdInput(e) {
this.setData({ switchAccountUserId: e.detail.value.trim() })
},
// 确认切换账号
async confirmSwitchAccount() {
const userId = this.data.switchAccountUserId.trim()
if (!userId || this.data.switchAccountLoading) return
this.setData({ switchAccountLoading: true })
try {
const res = await app.request('/api/miniprogram/dev/login-as', {
method: 'POST',
data: { userId }
})
if (res && res.success && res.data) {
const { token, user } = res.data
const openId = res.data.openId || ''
wx.setStorageSync('token', token)
wx.setStorageSync('userInfo', user)
app.globalData.userInfo = user
app.globalData.isLoggedIn = true
app.globalData.purchasedSections = user.purchasedSections || []
app.globalData.hasFullBook = user.hasFullBook || false
app.globalData.isVip = user.isVip || false
app.globalData.vipExpireDate = user.vipExpireDate || ''
if (openId) {
app.globalData.openId = openId
wx.setStorageSync('openId', openId)
}
this.setData({
showSwitchAccountModal: false,
switchAccountUserId: '',
switchAccountLoading: false
})
this.loadBindingInfo()
wx.showToast({ title: '已切换为 ' + (user.nickname || userId), icon: 'success' })
} else {
throw new Error(res?.error || '切换失败')
}
} catch (e) {
this.setData({ switchAccountLoading: false })
wx.showToast({ title: e.message || '切换失败', icon: 'none' })
}
},
// 清除缓存
clearCache() {
wx.showModal({

View File

@@ -113,21 +113,7 @@
<text class="tip-text">提示:绑定微信号才能使用提现功能</text>
</view>
<!-- 开发专用:切换账号(仅开发版显示) -->
<view class="dev-switch-card" wx:if="{{isDevMode}}" bindtap="openSwitchAccountModal">
<view class="dev-switch-inner">
<icon name="wrench" size="40" color="#8e8e93" customClass="dev-switch-icon"></icon>
<text class="dev-switch-text">切换账号(开发)</text>
<text class="dev-switch-desc">输入 userId 切换为其他账号调试</text>
</view>
</view>
<view class="dev-switch-card" wx:if="{{isDevMode}}" bindtap="goToDevLogin">
<view class="dev-switch-inner">
<icon name="smartphone" size="40" color="#8e8e93" customClass="dev-switch-icon"></icon>
<text class="dev-switch-text">账户密码登录</text>
<text class="dev-switch-desc">输入对方手机号登录,密码可留空</text>
</view>
</view>
<!-- 开发专用入口已移除 -->
<view class="logout-btn" wx:if="{{isLoggedIn}}" bindtap="handleLogout">退出登录</view>
</view>
@@ -164,28 +150,4 @@
</view>
</view>
<!-- 切换账号弹窗(开发) -->
<view class="modal-overlay" wx:if="{{showSwitchAccountModal}}" bindtap="closeSwitchAccountModal">
<view class="modal-content" catchtap="stopPropagation">
<view class="modal-header">
<text class="modal-title">切换账号(开发)</text>
<view class="modal-close" bindtap="closeSwitchAccountModal"><icon name="x" size="36" color="#8e8e93"></icon></view>
</view>
<view class="modal-body">
<view class="input-wrapper">
<input
class="form-input"
placeholder="请输入目标用户的 userId如 ogpTW5fmXRGNpoUbXB3UEqnVe5Tg"
placeholder-class="input-placeholder"
value="{{switchAccountUserId}}"
bindinput="onSwitchAccountUserIdInput"
/>
</view>
<text class="bind-tip">从管理端或数据库获取要调试的用户 ID切换后将以该用户身份操作</text>
<view class="btn-primary {{!switchAccountUserId || switchAccountLoading ? 'btn-disabled' : ''}}" bindtap="confirmSwitchAccount">
{{switchAccountLoading ? '切换中...' : '确认切换'}}
</view>
</view>
</view>
</view>
</view>

View File

@@ -1,4 +1,4 @@
import accessManager from '../../utils/chapterAccessManager'
const accessManager = require('../../utils/chapterAccessManager')
const app = getApp()
const { trackClick } = require('../../utils/trackClick')
@@ -12,16 +12,16 @@ Page({
originalPrice: 6980,
/* 按 premium_membership_landing_v1 设计稿 */
contentRights: [
{ title: '解锁全部章节', desc: '365天全案精读', icon: 'book-open' },
{ title: '案例库', desc: '100+创业实战案例', icon: 'book-open' },
{ title: '智能纪要', desc: 'AI每日精华推送', icon: 'lightbulb' },
{ title: '会议纪要库', desc: '往期完整沉淀', icon: 'folder' }
{ title: '匹配伙伴', desc: '精准匹配创业伙伴', icon: 'users' },
{ title: '派对专属', desc: '创业派对房专享', icon: 'star' },
{ title: '老板排行', desc: '创业老板排行榜', icon: 'bar-chart' },
{ title: '轮流置顶', desc: '首页获客曝光位', icon: 'arrow-up' }
],
socialRights: [
{ title: '匹配创业伙伴', desc: '精准人脉匹配', icon: 'users' },
{ title: '创业老板排行', desc: '项目曝光展示', icon: 'bar-chart' },
{ title: '链接资源', desc: '深度私域资源池', icon: 'link' },
{ title: '专属VIP标识', desc: '金色尊享光圈', icon: 'check' }
{ title: '案例宝库', desc: '100+赚钱案例库', icon: 'book-open' },
{ title: '全书解锁', desc: '365天全案精读', icon: 'folder' },
{ title: '每日总结', desc: 'AI每日精华推送', icon: 'lightbulb' },
{ title: '获取客资', desc: '文章@你即可获客', icon: 'link' }
],
purchasing: false
},

View File

@@ -4,7 +4,7 @@
"description": "卡若创业派对 - 来自派对房的真实商业故事",
"appid": "wxb8bbb2b10dec74aa",
"setting": {
"urlCheck": false,
"urlCheck": true,
"es6": true,
"enhance": true,
"postcss": true,

View File

@@ -27,8 +27,8 @@
"name": "开发登录",
"pathName": "pages/dev-login/dev-login",
"query": "",
"scene": null,
"launchMode": "default"
"launchMode": "default",
"scene": null
},
{
"name": "pages/member-detail/member-detail",

View File

@@ -27,6 +27,29 @@ function decodeEntities(str) {
.replace(/&#39;/g, "'")
}
/**
* 单行展示用:昵称、#标签文案、章节外标题类字段 — 合并换行、<br>、连续空白(避免 TipTap/粘贴带入异常断行)
*/
function cleanSingleLineField(s) {
if (!s && s !== 0) return ''
let t = decodeEntities(String(s))
.replace(/<br\s*\/?>/gi, ' ')
.replace(/\r\n|\r|\n/g, ' ')
.replace(/[\s\u00a0\u200b\u200c\u200d\ufeff\u3000]+/g, ' ')
.trim()
return t
}
/** @提及昵称:去首尾空白、零宽、全角空格;合并内部换行/<br> */
function cleanMentionNickname(n) {
return cleanSingleLineField(n)
}
/** 纯文本在 mention 节点前若已有「@」,去掉末尾 @,避免渲染成「找@@阿浪」 */
function stripTrailingAtForMention(before) {
return before.replace(/[@][\s\u00a0\u200b]*$/u, '')
}
/**
* 将一个 HTML block 字符串解析为 segments 数组
* 处理三种内联元素mention / linkTag(span) / linkTag(a) / img
@@ -39,20 +62,25 @@ function parseBlockToSegments(block) {
let m
while ((m = tokenRe.exec(block)) !== null) {
// 前置纯文本
const before = decodeEntities(block.slice(lastEnd, m.index).replace(/<[^>]+>/g, ''))
// 前置纯文本mention 紧挨手写「找@」时去掉重复 @
let before = decodeEntities(block.slice(lastEnd, m.index).replace(/<[^>]+>/g, ''))
const tag = m[0]
if (/data-type="mention"/i.test(tag)) {
before = stripTrailingAtForMention(before)
}
if (before.trim()) segs.push({ type: 'text', text: before })
const tag = m[0]
if (/data-type="mention"/i.test(tag)) {
// @mention — TipTap mention span
// @mention — TipTap mention spanspan 内常见「@ 昵称」多空格,统一紧挨显示)
const idMatch = tag.match(/data-id="([^"]*)"/)
const labelMatch = tag.match(/data-label="([^"]*)"/)
const innerText = tag.replace(/<[^>]+>/g, '')
const userId = idMatch ? idMatch[1].trim() : ''
const nickname = labelMatch ? labelMatch[1].trim() : innerText.replace(/^@/, '').trim()
if (userId || nickname) segs.push({ type: 'mention', userId, nickname })
let nickname = labelMatch ? labelMatch[1] : innerText.replace(/^[@]\s*/, '')
nickname = cleanMentionNickname((nickname || '').trim())
if (userId || nickname) {
segs.push({ type: 'mention', userId, nickname, mentionDisplay: '@' + nickname })
}
} else if (/data-type="linkTag"/i.test(tag)) {
// #linkTag — 自定义 span 格式data-type="linkTag" data-url="..." data-tag-type="..." data-page-path="..." data-app-id="..."
@@ -62,7 +90,7 @@ function parseBlockToSegments(block) {
const tagIdMatch = tag.match(/data-tag-id="([^"]*)"/)
const appIdMatch = tag.match(/data-app-id="([^"]*)"/)
const mpKeyMatch = tag.match(/data-mp-key="([^"]*)"/)
const innerText = tag.replace(/<[^>]+>/g, '').replace(/^#/, '').trim()
const innerText = cleanSingleLineField(tag.replace(/<[^>]+>/g, '').replace(/^#/, ''))
const url = urlMatch ? urlMatch[1] : ''
const tagType = tagTypeMatch ? tagTypeMatch[1] : 'url'
const pagePath = pagePathMatch ? pagePathMatch[1] : ''
@@ -75,7 +103,7 @@ function parseBlockToSegments(block) {
// #linkTag — 旧格式 <a href>insertLinkTag 旧版产生url 可能为空)
// m[1] = href, m[2] = innerText以 # 开头)
const url = m[1] || ''
const label = (m[2] || '').replace(/^#/, '').trim()
const label = cleanSingleLineField((m[2] || '').replace(/^#/, ''))
// 旧格式没有 tagType在 onLinkTagTap 中会按 label 匹配缓存的 linkTags 配置降级处理
segs.push({ type: 'linkTag', label: label || '#', url, tagType: '', pagePath: '', tagId: '' })
@@ -181,18 +209,31 @@ function stripMarkdownFormatting(text) {
/**
* 对一行纯文本进行 @人名 / #标签 自动匹配,返回 segments 数组
* config: { persons: [{personId, name, aliases}], linkTags: [{tagId, label, type, pagePath, mpKey, url, aliases}] }
* config: { persons: [{ personId, token, name, label, aliases }], linkTags: [...] }
* 点击加好友时须传 persons.token与 CKB lead 的 targetUserId 一致),不能用 personId。
*/
function matchLineToSegments(line, config) {
if (!config || (!config.persons?.length && !config.linkTags?.length)) {
return [{ type: 'text', text: line }]
}
// 编辑器/系统在 @ 与人名之间插入的普通空格,合并为紧挨 @(避免「找@ 阿浪」无法匹配人名)
line = line.replace(/([@])\s+(?=[\u4e00-\u9fffA-Za-z0-9_\u00b7])/g, '$1')
const normalize = s => (s || '').trim().toLowerCase()
const personMap = {}
const tagMap = {}
for (const p of (config.persons || [])) {
const keys = [p.name, ...(p.aliases ? p.aliases.split(',') : [])].map(normalize).filter(Boolean)
for (const k of keys) { if (!personMap[k]) personMap[k] = p }
const token = (p.token || '').trim()
if (!token) continue
const display = (p.name || p.label || '').trim()
const aliasStr = p.aliases != null ? String(p.aliases) : ''
const keys = [display, p.label, ...(aliasStr ? aliasStr.split(',') : [])]
.map((x) => (x != null ? String(x) : '').trim())
.filter(Boolean)
.map(normalize)
.filter(Boolean)
for (const k of keys) {
if (!personMap[k]) personMap[k] = p
}
}
for (const t of (config.linkTags || [])) {
const keys = [t.label, ...(t.aliases ? t.aliases.split(',') : [])].map(normalize).filter(Boolean)
@@ -204,8 +245,8 @@ function matchLineToSegments(line, config) {
if (!personNames.length && !tagLabels.length) return [{ type: 'text', text: line }]
const parts = []
if (personNames.length) parts.push('[@](' + personNames.join('|') + ')')
if (tagLabels.length) parts.push('[#](' + tagLabels.join('|') + ')')
if (personNames.length) parts.push('[@]\\s*(' + personNames.join('|') + ')')
if (tagLabels.length) parts.push('[#]\\s*(' + tagLabels.join('|') + ')')
const pattern = new RegExp(parts.join('|'), 'gi')
const segs = []
@@ -216,16 +257,22 @@ function matchLineToSegments(line, config) {
segs.push({ type: 'text', text: line.slice(lastEnd, m.index) })
}
const full = m[0]
const prefix = full[0]
const body = full.slice(1)
if (prefix === '@' || prefix === '') {
if (/^[@]/u.test(full)) {
const body = full.replace(/^[@]\s*/u, '')
const person = personMap[normalize(body)]
if (person) {
segs.push({ type: 'mention', userId: person.personId || '', nickname: person.name || body })
const nick = cleanSingleLineField(person.name || person.label || body)
const uid = (person.token || '').trim()
if (uid) {
segs.push({ type: 'mention', userId: uid, nickname: nick, mentionDisplay: '@' + nick })
} else {
segs.push({ type: 'text', text: full })
}
} else {
segs.push({ type: 'text', text: full })
}
} else {
const body = full.replace(/^[#]\s*/u, '')
const tag = tagMap[normalize(body)]
if (tag) {
segs.push({
@@ -285,5 +332,6 @@ function parseContent(rawContent, config) {
module.exports = {
parseContent,
isHtmlContent
isHtmlContent,
cleanSingleLineField,
}

View File

@@ -26,7 +26,8 @@ function getAppInstance() {
}
const RULE_COOLDOWN_KEY = 'rule_engine_cooldown'
const COOLDOWN_MS = 60 * 1000
// 0 = 关闭冷却(需求:去掉「操作频繁 / N 分钟」类体感限制)
const COOLDOWN_MS = 0
let _cachedRules = null
let _cacheTs = 0
const CACHE_TTL = 5 * 60 * 1000
@@ -45,6 +46,7 @@ const TRIGGER_SCENE_MAP = {
}
function isInCooldown(ruleId) {
if (!COOLDOWN_MS || COOLDOWN_MS <= 0) return false
try {
const map = wx.getStorageSync(RULE_COOLDOWN_KEY) || {}
const ts = map[ruleId]
@@ -57,6 +59,7 @@ function isInCooldown(ruleId) {
}
function setCooldown(ruleId) {
if (!COOLDOWN_MS || COOLDOWN_MS <= 0) return
try {
const map = wx.getStorageSync(RULE_COOLDOWN_KEY) || {}
map[ruleId] = Date.now()