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:
@@ -1,13 +1,92 @@
|
||||
/**
|
||||
* Soul创业实验 - 订单页
|
||||
* Soul创业实验 - 订单页(已支付消费流水:章节/VIP/余额/代付等)
|
||||
*/
|
||||
const app = getApp()
|
||||
const { cleanSingleLineField } = require('../../utils/contentParser.js')
|
||||
|
||||
const PAID_STATUSES = new Set(['paid', 'completed', 'success'])
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function formatShortDate(ms) {
|
||||
if (!ms) return '--'
|
||||
const d = new Date(ms)
|
||||
const m = (d.getMonth() + 1).toString().padStart(2, '0')
|
||||
const day = d.getDate().toString().padStart(2, '0')
|
||||
return `${m}-${day}`
|
||||
}
|
||||
|
||||
function midForSection(sectionId, bookFlat) {
|
||||
const row = bookFlat.find((s) => s.id === sectionId)
|
||||
return row?.mid ?? row?.MID ?? 0
|
||||
}
|
||||
|
||||
function classifyNav(productType, productId, mid) {
|
||||
const pt = String(productType || '').toLowerCase()
|
||||
if (pt === 'section' && productId) {
|
||||
return { kind: 'read', id: productId, mid: mid || 0, label: '阅读' }
|
||||
}
|
||||
if (pt === 'fullbook') {
|
||||
return { kind: 'switchTab', path: '/pages/chapters/chapters', label: '去目录' }
|
||||
}
|
||||
if (pt === 'vip') {
|
||||
return { kind: 'page', path: '/pages/vip/vip', label: '会员中心' }
|
||||
}
|
||||
if (pt === 'match') {
|
||||
return { kind: 'switchTab', path: '/pages/match/match', label: '找伙伴' }
|
||||
}
|
||||
if (pt === 'balance_recharge') {
|
||||
return { kind: 'page', path: '/pages/wallet/wallet', label: '余额' }
|
||||
}
|
||||
if (pt === 'gift_pay' || pt === 'gift_pay_batch') {
|
||||
return { kind: 'page', path: '/pages/gift-pay/list', label: '代付记录' }
|
||||
}
|
||||
if (productId && (/^\d+\.\d+/.test(productId) || productId.length > 0)) {
|
||||
return { kind: 'read', id: productId, mid: mid || 0, label: '阅读' }
|
||||
}
|
||||
return { kind: 'none', label: '--' }
|
||||
}
|
||||
|
||||
function mapApiOrderToRow(item, bookFlat) {
|
||||
const status = String(item.status || '').toLowerCase()
|
||||
if (!PAID_STATUSES.has(status)) return null
|
||||
|
||||
const pt = String(item.product_type || '').toLowerCase()
|
||||
const productId = String(item.product_id || item.section_id || '').trim()
|
||||
let mid = Number(item.section_mid ?? item.mid ?? item.MID ?? 0) || 0
|
||||
if (pt === 'section' && productId && !mid) mid = midForSection(productId, bookFlat)
|
||||
|
||||
const titleRaw = cleanSingleLineField(item.product_name || '')
|
||||
const title =
|
||||
titleRaw ||
|
||||
(pt === 'balance_recharge' ? '余额充值' : productId ? `订单 ${productId}` : '消费记录')
|
||||
|
||||
const amt = Number(item.amount)
|
||||
const amountStr = Number.isFinite(amt) ? amt.toFixed(2) : '--'
|
||||
const t = parseOrderTimeMs(item)
|
||||
const nav = classifyNav(pt, productId, mid)
|
||||
|
||||
return {
|
||||
rowKey: String(item.order_sn || item.id || `o_${t}`),
|
||||
title,
|
||||
subLine: `¥${amountStr} · ${formatShortDate(t)}`,
|
||||
actionLabel: nav.label,
|
||||
nav,
|
||||
_sortMs: t
|
||||
}
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 44,
|
||||
orders: [],
|
||||
loading: true
|
||||
loading: true,
|
||||
allRows: [],
|
||||
displayRows: [],
|
||||
historyExpanded: false
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
@@ -16,63 +95,95 @@ Page({
|
||||
this.loadOrders()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
if (!this._purchasesFirstOnShowSkipped) {
|
||||
this._purchasesFirstOnShowSkipped = true
|
||||
return
|
||||
}
|
||||
if (app.globalData.isLoggedIn) this.loadOrders()
|
||||
},
|
||||
|
||||
applyDisplay(expanded) {
|
||||
const all = this.data.allRows || []
|
||||
const display = expanded || all.length <= 5 ? all : all.slice(0, 5)
|
||||
this.setData({ displayRows: display, historyExpanded: !!expanded })
|
||||
},
|
||||
|
||||
expandHistory() {
|
||||
if (this.data.historyExpanded) return
|
||||
this.applyDisplay(true)
|
||||
},
|
||||
|
||||
async loadOrders() {
|
||||
this.setData({ loading: true })
|
||||
const bookFlat = Array.isArray(app.globalData.bookData) ? app.globalData.bookData : []
|
||||
const userId = app.globalData.userInfo?.id
|
||||
|
||||
try {
|
||||
const userId = app.globalData.userInfo?.id
|
||||
if (userId) {
|
||||
const res = await app.request(`/api/miniprogram/orders?userId=${userId}`)
|
||||
if (res && res.success && res.data) {
|
||||
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() : '--',
|
||||
_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 })
|
||||
const res = await app.request({
|
||||
url: `/api/miniprogram/orders?userId=${encodeURIComponent(userId)}`,
|
||||
silent: true
|
||||
})
|
||||
if (res && res.success && Array.isArray(res.data)) {
|
||||
const rows = res.data
|
||||
.map((item) => mapApiOrderToRow(item, bookFlat))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b._sortMs - a._sortMs)
|
||||
.map(({ _sortMs, ...rest }) => rest)
|
||||
this.setData({ allRows: rows, loading: false })
|
||||
this.applyDisplay(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
const purchasedSections = [...(app.globalData.purchasedSections || [])].reverse()
|
||||
const orders = purchasedSections.map((id, index) => ({
|
||||
id: `order_${index}`,
|
||||
sectionId: id,
|
||||
sectionMid: 0,
|
||||
title: `章节 ${id}`,
|
||||
amount: 1,
|
||||
status: 'completed',
|
||||
createTime: new Date(Date.now() - index * 86400000).toLocaleDateString()
|
||||
}))
|
||||
this.setData({ orders })
|
||||
const ids = [...(app.globalData.purchasedSections || [])].reverse()
|
||||
const rows = ids.map((id, index) => {
|
||||
const mid = midForSection(id, bookFlat)
|
||||
const row = bookFlat.find((s) => s.id === id)
|
||||
const title =
|
||||
cleanSingleLineField(
|
||||
row?.sectionTitle || row?.section_title || row?.title || row?.chapterTitle || ''
|
||||
) || `章节 ${id}`
|
||||
const t = Date.now() - index * 86400000
|
||||
return {
|
||||
rowKey: `p_${id}_${index}`,
|
||||
title,
|
||||
subLine: `已解锁 · ${formatShortDate(t)}`,
|
||||
actionLabel: '阅读',
|
||||
nav: { kind: 'read', id, mid, label: '阅读' }
|
||||
}
|
||||
})
|
||||
this.setData({ allRows: rows, loading: false })
|
||||
this.applyDisplay(false)
|
||||
} catch (e) {
|
||||
console.error('加载订单失败:', e)
|
||||
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',
|
||||
createTime: new Date(Date.now() - i * 86400000).toLocaleDateString()
|
||||
}))
|
||||
})
|
||||
} finally {
|
||||
this.setData({ loading: false })
|
||||
this.setData({ allRows: [], loading: false })
|
||||
this.applyDisplay(false)
|
||||
}
|
||||
},
|
||||
|
||||
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}` })
|
||||
onOrderRowTap(e) {
|
||||
const index = e.currentTarget.dataset.index
|
||||
const row = (this.data.displayRows || [])[index]
|
||||
if (!row || !row.nav) return
|
||||
const { nav } = row
|
||||
if (nav.kind === 'read' && nav.id) {
|
||||
const q = nav.mid ? `mid=${nav.mid}` : `id=${nav.id}`
|
||||
wx.navigateTo({ url: `/pages/read/read?${q}` })
|
||||
return
|
||||
}
|
||||
if (nav.kind === 'page' && nav.path) {
|
||||
wx.navigateTo({ url: nav.path })
|
||||
return
|
||||
}
|
||||
if (nav.kind === 'switchTab' && nav.path) {
|
||||
wx.switchTab({ url: nav.path })
|
||||
}
|
||||
},
|
||||
|
||||
goBack() { getApp().goBackOrToHome() },
|
||||
goBack() {
|
||||
getApp().goBackOrToHome()
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
const ref = app.getMyReferralCode()
|
||||
|
||||
@@ -14,20 +14,37 @@
|
||||
<view class="skeleton"></view>
|
||||
</view>
|
||||
|
||||
<view class="orders-list" wx:elif="{{orders.length > 0}}">
|
||||
<view class="order-item" wx:for="{{orders}}" wx:key="id" bindtap="goToRead" data-id="{{item.sectionId}}" data-mid="{{item.sectionMid}}">
|
||||
<view class="order-info">
|
||||
<view class="order-title-row">
|
||||
<text class="order-unlock-icon">🔓</text>
|
||||
<text class="order-title">{{item.title}}</text>
|
||||
<view class="order-history-card" wx:elif="{{displayRows.length > 0}}">
|
||||
<view class="order-history-head">
|
||||
<image class="order-history-icon" src="/assets/icons/unlock-muted-teal.svg" mode="aspectFit"/>
|
||||
</view>
|
||||
<view class="oh-list">
|
||||
<view
|
||||
class="oh-row"
|
||||
wx:for="{{displayRows}}"
|
||||
wx:key="rowKey"
|
||||
bindtap="onOrderRowTap"
|
||||
data-index="{{index}}"
|
||||
>
|
||||
<view class="oh-left">
|
||||
<text class="oh-index">{{index + 1}}</text>
|
||||
<view class="oh-text-wrap">
|
||||
<text class="oh-title">{{item.title}}</text>
|
||||
<text class="oh-sub" wx:if="{{item.subLine}}">{{item.subLine}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="order-time">{{item.createTime}}</text>
|
||||
</view>
|
||||
<view class="order-right">
|
||||
<text class="order-amount">¥{{item.amount}}</text>
|
||||
<text class="order-status">已完成</text>
|
||||
<text class="oh-link">{{item.actionLabel}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
class="oh-expand"
|
||||
wx:if="{{allRows.length > 5 && !historyExpanded}}"
|
||||
bindtap="expandHistory"
|
||||
hover-class="oh-expand-hover"
|
||||
hover-stay-time="80"
|
||||
>
|
||||
<view class="oh-triangle"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="empty" wx:else>
|
||||
|
||||
@@ -7,17 +7,29 @@
|
||||
.loading { display: flex; flex-direction: column; gap: 24rpx; }
|
||||
.skeleton { height: 120rpx; background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%); background-size: 200% 100%; animation: skeleton 1.5s ease-in-out infinite; border-radius: 24rpx; }
|
||||
@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-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; }
|
||||
.order-status { font-size: 22rpx; color: rgba(255,255,255,0.4); }
|
||||
.order-history-card { background: #1c1c1e; border-radius: 24rpx; padding: 28rpx 24rpx 16rpx; }
|
||||
.order-history-head { padding: 0 8rpx 16rpx 8rpx; }
|
||||
.order-history-icon { width: 40rpx; height: 40rpx; opacity: 0.92; }
|
||||
.oh-list { display: flex; flex-direction: column; gap: 16rpx; }
|
||||
.oh-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 24rpx; background: #252525; border-radius: 20rpx;
|
||||
}
|
||||
.oh-row:active { opacity: 0.92; }
|
||||
.oh-left { display: flex; align-items: center; gap: 24rpx; overflow: hidden; min-width: 0; flex: 1; }
|
||||
.oh-text-wrap { display: flex; flex-direction: column; gap: 6rpx; min-width: 0; flex: 1; }
|
||||
.oh-index { font-size: 28rpx; color: #6B7280; font-family: monospace; flex-shrink: 0; }
|
||||
.oh-title { font-size: 28rpx; color: #E5E7EB; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.oh-sub { font-size: 22rpx; color: rgba(255,255,255,0.4); }
|
||||
.oh-link { font-size: 24rpx; color: #00CED1; font-weight: 500; flex-shrink: 0; margin-left: 16rpx; }
|
||||
.oh-expand { display: flex; justify-content: center; align-items: center; padding: 8rpx 0 8rpx; margin-top: 8rpx; }
|
||||
.oh-expand-hover { opacity: 0.65; }
|
||||
.oh-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);
|
||||
}
|
||||
.empty { display: flex; flex-direction: column; align-items: center; padding: 96rpx; }
|
||||
.empty-icon { font-size: 96rpx; margin-bottom: 24rpx; opacity: 0.5; }
|
||||
.empty-text { font-size: 28rpx; color: rgba(255,255,255,0.4); }
|
||||
|
||||
Reference in New Issue
Block a user