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:
178
miniprogram/pages/reading-records/reading-records.js
Normal file
178
miniprogram/pages/reading-records/reading-records.js
Normal 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()
|
||||
}
|
||||
})
|
||||
4
miniprogram/pages/reading-records/reading-records.json
Normal file
4
miniprogram/pages/reading-records/reading-records.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"usingComponents": {},
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
51
miniprogram/pages/reading-records/reading-records.wxml
Normal file
51
miniprogram/pages/reading-records/reading-records.wxml
Normal 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>
|
||||
25
miniprogram/pages/reading-records/reading-records.wxss
Normal file
25
miniprogram/pages/reading-records/reading-records.wxss
Normal 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; }
|
||||
Reference in New Issue
Block a user