feat: 小程序阅读记录与资料链路、管理端用户规则、API/VIP/推荐与运营脚本

- miniprogram: reading-records、imageUrl/mpNavigate、多页资料与 VIP 展示调整
- soul-admin: Users/Settings/UserDetailModal、dist 构建产物更新
- soul-api: user/vip/referral/ckb/db、MBTI 头像管理、user_rule_completion、迁移 SQL
- .cursor: karuo-party 与飞书文档;.gitignore 忽略 .tmp_skill_bundle

Made-with: Cursor
This commit is contained in:
卡若
2026-03-23 18:38:23 +08:00
parent cb6e2bff56
commit fa3da12b16
82 changed files with 5621 additions and 2723 deletions

View File

@@ -0,0 +1,178 @@
/**
* 阅读记录:最近阅读 + 已读章节(与「我的」数据源一致)
*/
const app = getApp()
const { cleanSingleLineField } = require('../../utils/contentParser.js')
function titleFromBookData(sectionId, bookFlat) {
const row = bookFlat.find((s) => s.id === sectionId)
return cleanSingleLineField(
row?.sectionTitle || row?.section_title || row?.title || row?.chapterTitle || ''
) || `章节 ${sectionId}`
}
function midFromBookData(sectionId, bookFlat) {
const row = bookFlat.find((s) => s.id === sectionId)
return row?.mid ?? row?.MID ?? 0
}
function mergeRecentFromLocal(apiList) {
const normalized = Array.isArray(apiList)
? apiList.map((item) => ({
id: item.id,
mid: item.mid,
title: cleanSingleLineField(item.title || '') || `章节 ${item.id}`
}))
: []
if (normalized.length > 0) return normalized
try {
const progressData = wx.getStorageSync('reading_progress') || {}
const bookFlat = Array.isArray(app.globalData.bookData) ? app.globalData.bookData : []
return Object.keys(progressData)
.map((id) => ({
id,
ts: progressData[id]?.lastOpenAt || progressData[id]?.last_open_at || 0
}))
.filter((e) => e.id)
.sort((a, b) => b.ts - a.ts)
.slice(0, 20)
.map((e) => ({
id: e.id,
mid: midFromBookData(e.id, bookFlat),
title: titleFromBookData(e.id, bookFlat)
}))
} catch (e) {
return []
}
}
Page({
data: {
statusBarHeight: 44,
isLoggedIn: false,
focus: 'all',
recentList: [],
readAllList: [],
recentSectionTitle: '最近阅读',
readSectionTitle: '已读章节'
},
onLoad(options) {
const focus = (options.focus === 'recent' || options.focus === 'read') ? options.focus : 'all'
this.setData({
statusBarHeight: app.globalData.statusBarHeight || 44,
focus
})
this._applyMpUiTitles()
},
onShow() {
this.setData({ isLoggedIn: !!(app.globalData.isLoggedIn && app.globalData.userInfo?.id) })
this._applyMpUiTitles()
if (this.data.isLoggedIn) this.loadData()
},
_applyMpUiTitles() {
const my = app.globalData.configCache?.mpConfig?.mpUi?.myPage || {}
this.setData({
recentSectionTitle: my.recentReadTitle || '最近阅读',
readSectionTitle: my.readStatLabel || '已读章节'
})
},
async _ensureBookFlat() {
let flat = Array.isArray(app.globalData.bookData) ? app.globalData.bookData : []
if (flat.length) return flat
try {
const r = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
const list = r?.data
if (Array.isArray(list) && list.length) {
app.globalData.bookData = list
return list
}
} catch (_) {}
return []
},
async loadData() {
const userId = app.globalData.userInfo?.id
if (!userId) return
try {
const res = await app.request({
url: `/api/miniprogram/user/dashboard-stats?userId=${encodeURIComponent(userId)}`,
silent: true
})
const bookFlat = await this._ensureBookFlat()
let recent = []
let readIds = []
if (res?.success && res.data) {
const apiRecent = Array.isArray(res.data.recentChapters) ? res.data.recentChapters : []
recent = mergeRecentFromLocal(apiRecent)
readIds = Array.isArray(res.data.readSectionIds) ? res.data.readSectionIds.filter(Boolean) : []
} else {
recent = mergeRecentFromLocal([])
}
try {
const progressData = wx.getStorageSync('reading_progress') || {}
const fromKeys = Object.keys(progressData).filter(Boolean)
const stored = wx.getStorageSync('readSectionIds')
const fromStored = Array.isArray(stored) ? stored.filter(Boolean) : []
const fromGlobal = Array.isArray(app.globalData.readSectionIds)
? app.globalData.readSectionIds.filter(Boolean)
: []
readIds = [...new Set([...readIds, ...fromGlobal, ...fromStored, ...fromKeys])]
} catch (_) {}
if (readIds.length === 0 && recent.length > 0) {
readIds = recent.map((r) => r.id)
}
const readAllList = readIds.map((id) => ({
id,
mid: midFromBookData(id, bookFlat),
title: titleFromBookData(id, bookFlat)
}))
this.setData({ recentList: recent, readAllList })
} catch (e) {
console.warn('[reading-records]', e)
try {
const bookFlat = await this._ensureBookFlat()
let readIds = Array.isArray(app.globalData.readSectionIds) ? [...app.globalData.readSectionIds] : []
if (!readIds.length) {
try {
const stored = wx.getStorageSync('readSectionIds')
if (Array.isArray(stored)) readIds = [...stored]
} catch (_) {}
}
const recent = mergeRecentFromLocal([])
if (!readIds.length && recent.length) readIds = recent.map((r) => r.id)
const readAllList = readIds.map((id) => ({
id,
mid: midFromBookData(id, bookFlat),
title: titleFromBookData(id, bookFlat)
}))
this.setData({ recentList: recent, readAllList })
} catch (_) {
this.setData({ recentList: [], readAllList: [] })
}
}
},
goRead(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}` })
},
goChapters() {
wx.switchTab({ url: '/pages/chapters/chapters' })
},
goLogin() {
wx.switchTab({ url: '/pages/my/my' })
},
goBack() {
getApp().goBackOrToHome()
}
})

View File

@@ -0,0 +1,4 @@
{
"usingComponents": {},
"navigationStyle": "custom"
}

View File

@@ -0,0 +1,51 @@
<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="rgba(255,255,255,0.8)" customClass="back-icon"></icon></view>
<text class="nav-title">阅读记录</text>
<view class="nav-placeholder"></view>
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<view class="content" wx:if="{{isLoggedIn}}">
<view class="section" wx:if="{{focus === 'all' || focus === 'recent'}}">
<view class="section-head">
<text class="section-title">{{recentSectionTitle}}</text>
<text class="section-count" wx:if="{{recentList.length > 0}}">{{recentList.length}} 条</text>
</view>
<view class="list" wx:if="{{recentList.length > 0}}">
<view class="row" wx:for="{{recentList}}" wx:key="id" bindtap="goRead" data-id="{{item.id}}" data-mid="{{item.mid}}">
<text class="row-idx">{{index + 1}}</text>
<text class="row-title">{{item.title}}</text>
<text class="row-link">阅读</text>
</view>
</view>
<view class="empty" wx:else>
<text class="empty-t">暂无最近阅读</text>
<text class="empty-a" bindtap="goChapters">去目录逛逛</text>
</view>
</view>
<view class="section" wx:if="{{focus === 'all' || focus === 'read'}}">
<view class="section-head">
<text class="section-title">{{readSectionTitle}}</text>
<text class="section-count" wx:if="{{readAllList.length > 0}}">共 {{readAllList.length}} 章</text>
</view>
<view class="list" wx:if="{{readAllList.length > 0}}">
<view class="row" wx:for="{{readAllList}}" wx:key="id" bindtap="goRead" data-id="{{item.id}}" data-mid="{{item.mid}}">
<text class="row-idx">{{index + 1}}</text>
<text class="row-title">{{item.title}}</text>
<text class="row-link">阅读</text>
</view>
</view>
<view class="empty" wx:else>
<text class="empty-t">暂无已读记录</text>
<text class="empty-a" bindtap="goChapters">去目录选章阅读</text>
</view>
</view>
</view>
<view class="guest" wx:else>
<text class="guest-t">登录后查看阅读记录</text>
<view class="guest-btn" bindtap="goLogin">去登录</view>
</view>
</view>

View File

@@ -0,0 +1,25 @@
.page { min-height: 100vh; background: #000; padding-bottom: 48rpx; }
.nav-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 100; background: rgba(0,0,0,0.92); display: flex; align-items: center; justify-content: space-between; padding: 0 32rpx; height: 88rpx; }
.nav-back { width: 72rpx; height: 72rpx; display: flex; align-items: center; justify-content: center; }
.nav-title { font-size: 36rpx; font-weight: 600; color: #00CED1; flex: 1; text-align: center; }
.nav-placeholder { width: 72rpx; }
.content { padding: 32rpx; }
.section { margin-bottom: 48rpx; }
.section-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 24rpx; }
.section-title { font-size: 32rpx; font-weight: 600; color: #e5e7eb; }
.section-count { font-size: 24rpx; color: #6b7280; }
.list { display: flex; flex-direction: column; gap: 16rpx; }
.row {
display: flex; align-items: center; gap: 20rpx;
padding: 24rpx; background: #1c1c1e; border-radius: 20rpx;
}
.row:active { opacity: 0.9; }
.row-idx { font-size: 26rpx; color: #6b7280; font-family: monospace; flex-shrink: 0; }
.row-title { flex: 1; min-width: 0; font-size: 28rpx; color: #fff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row-link { font-size: 24rpx; color: #00CED1; flex-shrink: 0; }
.empty { padding: 48rpx 24rpx; text-align: center; }
.empty-t { display: block; font-size: 28rpx; color: #6b7280; margin-bottom: 24rpx; }
.empty-a { font-size: 28rpx; color: #00CED1; }
.guest { padding: 120rpx 48rpx; text-align: center; }
.guest-t { display: block; font-size: 30rpx; color: #9ca3af; margin-bottom: 32rpx; }
.guest-btn { display: inline-block; padding: 20rpx 48rpx; background: #00CED1; color: #000; font-size: 28rpx; font-weight: 600; border-radius: 24rpx; }