feat: 优化UI

This commit is contained in:
Blizzard
2026-03-05 17:04:40 +08:00
parent 0a61c4ddec
commit 7f51b2a0a8
28 changed files with 1773 additions and 964 deletions
+5
View File
@@ -191,8 +191,13 @@ App({
self.globalData.isLoggedIn = true self.globalData.isLoggedIn = true
self.globalData.token = token self.globalData.token = token
self.globalData.userInfo = user self.globalData.userInfo = user
self.globalData.isVip = user.isVip === 1
self.globalData.vipExpireAt = user.vipExpireAt || null
wx.setStorageSync('token', token) wx.setStorageSync('token', token)
self.emit('loginStateChange', { isLoggedIn: true }) self.emit('loginStateChange', { isLoggedIn: true })
if (user.isVip === 1) {
self.emit('vipChange', { isVip: true })
}
resolve(res.data) resolve(res.data)
} else { } else {
reject(new Error(res.msg || '登录失败')) reject(new Error(res.msg || '登录失败'))
+53 -21
View File
@@ -13,10 +13,12 @@ Page({
data: { data: {
domain: {}, domain: {},
isSubscribed: false, isSubscribed: false,
isExpired: false, // 订阅是否已过期 isExpired: false,
expiredAt: '', // 到期时间(格式化) expiredAt: '',
isFree: false, // 快捷字段,避免模板 domain.isFree isFree: false,
isVipOnly: false, isVipOnly: false,
isVip: false,
canPlay: false,
domainContents: [], domainContents: [],
isPlaying: false, isPlaying: false,
loading: true loading: true
@@ -52,7 +54,6 @@ Page({
const ch = res.data const ch = res.data
const isFree = ch.isFree === 1 const isFree = ch.isFree === 1
// 免费频道:不关心订阅状态和到期时间
var expiredAt = '' var expiredAt = ''
var isExpired = false var isExpired = false
var isSubscribed = false var isSubscribed = false
@@ -64,13 +65,18 @@ Page({
isSubscribed = ch.hasSubscribed === 1 && !isExpired isSubscribed = ch.hasSubscribed === 1 && !isExpired
} }
// 可播放:VIP 或免费频道或已订阅
const canPlay = app.globalData.isVip || isFree || isSubscribed
self.setData({ self.setData({
domain: ch, domain: ch,
isSubscribed, isSubscribed,
isExpired, isExpired,
expiredAt, expiredAt,
isFree, isFree,
isVipOnly: ch.isVipOnly === 1 isVipOnly: ch.isVipOnly === 1,
isVip: app.globalData.isVip,
canPlay
}) })
} }
}).catch(function (err) { }).catch(function (err) {
@@ -92,8 +98,7 @@ Page({
} }
var gd = app.globalData var gd = app.globalData
var isSubscribed = self.data.isSubscribed var canPlay = self.data.canPlay
var isFree = self.data.domain.isFree === 1
var total = contents.length var total = contents.length
contents = contents.map(function (item, idx) { contents = contents.map(function (item, idx) {
@@ -102,7 +107,7 @@ Page({
_dateDot: item.createdAt ? item.createdAt.substring(0, 10).replace(/-/g, '.') : '', _dateDot: item.createdAt ? item.createdAt.substring(0, 10).replace(/-/g, '.') : '',
durationText: util.formatTime(item.duration || 0), durationText: util.formatTime(item.duration || 0),
_isThisPlaying: gd.activeContent && gd.activeContent.id === item.id, _isThisPlaying: gd.activeContent && gd.activeContent.id === item.id,
_isLocked: !isSubscribed && !isFree && idx > 0 _isLocked: !canPlay // 所有集全部锁定,没有试听
}) })
}) })
@@ -133,11 +138,12 @@ Page({
onPlayItem(e) { onPlayItem(e) {
const id = e.currentTarget.dataset.id const id = e.currentTarget.dataset.id
const idx = parseInt(e.currentTarget.dataset.idx)
const gd = app.globalData const gd = app.globalData
if (!this.data.isSubscribed && !(this.data.domain.isFree === 1) && idx > 0) { // 核心权限判断:VIP || 免费频道 || 已订阅
wx.showToast({ title: '请先订阅该频道以解锁往期内容', icon: 'none' }) if (!this.data.canPlay) {
// 引导到支付页
this.onSubscribe()
return return
} }
@@ -161,25 +167,51 @@ Page({
const id = this._domainId const id = this._domainId
const domain = this.data.domain const domain = this.data.domain
// 已订阅 →订阅中,无需操作 // 已可播放(VIP / 免费 / 已订阅)——正常情况下不会触发此方法
if (this.data.isSubscribed) { if (this.data.canPlay) {
wx.showToast({ title: '您已订阅该频道', icon: 'none' }) wx.showToast({ title: '您已可收听该频道', icon: 'none' })
return return
} }
// 免费频道 → 直接收听 // VIP专享频道:不支持单独订阅,必须开通 VIP
if (domain.isVipOnly === 1) {
wx.navigateTo({ url: '/pages/vip/index' })
return
}
// 免费频道(理论上不会到这里,安全先)
if (domain.isFree === 1) { if (domain.isFree === 1) {
wx.showToast({ title: '免费频道,直接收听!', icon: 'none' }) wx.showToast({ title: '免费频道,直接收听!', icon: 'none' })
return return
} }
// VIP专享且未开通 → VIP // 付费频道(未订阅 / 已过期)——跳转订阅/支付
if (domain.isVipOnly === 1 && !app.globalData.isVip) { var params = 'channelId=' + id
wx.navigateTo({ url: '/pages/vip/index' }) + '&channelName=' + encodeURIComponent(domain.name || '')
return + '&monthlyPrice=' + (domain.monthlyPrice || 0)
} + '&quarterlyPrice=' + (domain.quarterlyPrice || 0)
+ '&annualPrice=' + (domain.annualPrice || 0)
wx.navigateTo({ url: '/pages/vip/index?' + params })
},
// 付费频道(含已过期续费)→ 跳转订阅/支付页 goFirstProgram() {
var list = this.data.domainContents
if (list && list.length > 0) {
var first = list[0]
if (app.globalData.activeContent && app.globalData.activeContent.id === first.id) {
app.togglePlay()
} else {
app.playContent(first)
}
} else {
wx.showToast({ title: '暂无节目', icon: 'none' })
}
},
/** 续订:直接跳支付页,不走 canPlay 检查 */
onRenew() {
const id = this._domainId
const domain = this.data.domain
var params = 'channelId=' + id var params = 'channelId=' + id
+ '&channelName=' + encodeURIComponent(domain.name || '') + '&channelName=' + encodeURIComponent(domain.name || '')
+ '&monthlyPrice=' + (domain.monthlyPrice || 0) + '&monthlyPrice=' + (domain.monthlyPrice || 0)
+38 -33
View File
@@ -9,10 +9,27 @@
<text class="hero-name">{{domain.name}}</text> <text class="hero-name">{{domain.name}}</text>
<text class="hero-tag">{{domain.tag || domain.description || ''}}</text> <text class="hero-tag">{{domain.tag || domain.description || ''}}</text>
<!-- ═══ 按钮区:根据频道类型和订阅状态分情况 ═══ --> <!-- ═══ 按钮区 ═══ -->
<!-- 1. 免费频道 --> <!-- 0. 可播放(VIP / 免费 / 已订阅)→ 直接收听 -->
<block wx:if="{{isFree}}"> <block wx:if="{{canPlay}}">
<button class="hero-sub-btn free-btn" bindtap="goFirstProgram">
<text>▶ 开始收听</text>
</button>
<!-- 副标签 -->
<text wx:if="{{isVip}}" class="hero-expired">👑 VIP 会员畅享</text>
<text wx:elif="{{isFree}}" class="hero-expired">🎁 永久免费频道</text>
<view wx:elif="{{isSubscribed}}" class="hero-sub-row">
<text class="hero-expired">有效至 {{expiredAt || '长期有效'}}</text>
<!-- 续订按钮:付费已订阅频道才显示 -->
<view wx:if="{{!isVipOnly}}" class="renew-inline-btn tap-active" bindtap="onRenew">
<text class="renew-inline-text">续订</text>
</view>
</view>
</block>
<!-- 1. 免费频道(但不可播放,理论上不会到这里) -->
<block wx:elif="{{isFree}}">
<view class="hero-badge free-badge">🎁 永久免费</view> <view class="hero-badge free-badge">🎁 永久免费</view>
<button class="hero-sub-btn free-btn" bindtap="onSubscribe"> <button class="hero-sub-btn free-btn" bindtap="onSubscribe">
<text>▶ 开始收听</text> <text>▶ 开始收听</text>
@@ -27,24 +44,16 @@
</button> </button>
</block> </block>
<!-- 3. 订阅且有效 --> <!-- 3. 订阅已过期 -->
<block wx:elif="{{isSubscribed}}">
<button class="hero-sub-btn subscribed" bindtap="onSubscribe">
<text>✓ 已订阅</text>
</button>
<text wx:if="{{expiredAt}}" class="hero-expired">有效至 {{expiredAt}}</text>
</block>
<!-- 4. 订阅已过期(重新订阅) -->
<block wx:elif="{{isExpired}}"> <block wx:elif="{{isExpired}}">
<view class="hero-badge expired-badge">⏰ 订阅已到期</view> <view class="hero-badge expired-badge">⏰ 订阅已到期</view>
<button class="hero-sub-btn renew-btn" bindtap="onSubscribe"> <button class="hero-sub-btn renew-btn" bindtap="onSubscribe">
<text>续费订阅</text> <text>续费订阅</text>
</button> </button>
<text wx:if="{{expiredAt}}" class="hero-expired">已于 {{expiredAt}} 到期</text> <text class="hero-expired">已于 {{expiredAt}} 到期</text>
</block> </block>
<!-- 5. 未订阅付费频道 --> <!-- 4. 未订阅付费频道 -->
<block wx:else> <block wx:else>
<button class="hero-sub-btn" bindtap="onSubscribe"> <button class="hero-sub-btn" bindtap="onSubscribe">
<text>订阅频道</text> <text>订阅频道</text>
@@ -61,7 +70,7 @@
<!-- 提示条:根据状态动态切换 --> <!-- 提示条:根据状态动态切换 -->
<!-- VIP专享未开通 --> <!-- VIP专享未开通 -->
<view wx:if="{{isVipOnly && !isSubscribed}}" class="trial-notice vip-notice" bindtap="onSubscribe"> <view wx:if="{{isVipOnly && !canPlay}}" class="trial-notice vip-notice" bindtap="onSubscribe">
<text class="notice-icon">👑</text> <text class="notice-icon">👑</text>
<view class="notice-info"> <view class="notice-info">
<text class="notice-title">VIP专属频道</text> <text class="notice-title">VIP专属频道</text>
@@ -80,14 +89,14 @@
<text class="notice-action">续费 </text> <text class="notice-action">续费 </text>
</view> </view>
<!-- 付费频道未订阅(试听) --> <!-- 付费频道未订阅(全锁,无试听) -->
<view wx:elif="{{!isSubscribed && !isFree}}" class="trial-notice" bindtap="onSubscribe"> <view wx:elif="{{!canPlay && !isFree}}" class="trial-notice locked-notice" bindtap="onSubscribe">
<text class="notice-icon">🔒</text> <text class="notice-icon">🔐</text>
<view class="notice-info"> <view class="notice-info">
<text class="notice-title">试听模式</text> <text class="notice-title">频道未解锁</text>
<text class="notice-desc">可试听最新一期,订阅后解锁全部历史内容</text> <text class="notice-desc">订阅频道或开通全频道会员,即可畅听全部内容</text>
</view> </view>
<text class="notice-action">订阅 </text> <text class="notice-action">立即解锁 </text>
</view> </view>
<!-- 内容列表标题 --> <!-- 内容列表标题 -->
@@ -119,18 +128,14 @@
</view> </view>
</view> </view>
<!-- 播放按钮 --> <!-- 播放 / 锁定按钮 -->
<view wx:if="{{!item._isLocked}}" class="item-play-btn {{item._isThisPlaying ? 'active' : ''}}"> <view class="item-play-btn {{item._isThisPlaying ? 'active' : ''}} {{item._isLocked ? 'locked-btn' : ''}}">
<image <text wx:if="{{item._isLocked}}" class="lock-icon">🔒</text>
wx:if="{{item._isThisPlaying && isPlaying}}" <view wx:elif="{{item._isThisPlaying && isPlaying}}" class="mini-pause">
src="/assets/icons/pause.svg" <view class="mp-bar"></view>
class="item-play-icon" <view class="mp-bar"></view>
/> </view>
<image <text wx:else class="play-tri">▶</text>
wx:else
src="/assets/icons/play.svg"
class="item-play-icon"
/>
</view> </view>
</view> </view>
</view> </view>
+52 -5
View File
@@ -86,6 +86,24 @@
color: rgba(255, 255, 255, 0.7); color: rgba(255, 255, 255, 0.7);
letter-spacing: 0.5rpx; letter-spacing: 0.5rpx;
} }
/* 到期日 + 续订按钮 同一行 */
.hero-sub-row {
display: flex;
align-items: center;
gap: 16rpx;
margin-top: 12rpx;
}
.renew-inline-btn {
background: rgba(255, 255, 255, 0.2);
border: 1rpx solid rgba(255, 255, 255, 0.5);
border-radius: 32rpx;
padding: 6rpx 20rpx;
}
.renew-inline-text {
font-size: 20rpx;
color: #FFF;
font-weight: 600;
}
/* 徽标(免费 / VIP / 到期)*/ /* 徽标(免费 / VIP / 到期)*/
.hero-badge { .hero-badge {
@@ -286,10 +304,39 @@
border-color: transparent; border-color: transparent;
box-shadow: 0 4rpx 16rpx rgba(255, 157, 66, 0.3); box-shadow: 0 4rpx 16rpx rgba(255, 157, 66, 0.3);
} }
.item-play-icon { /* 锁定状态的圆圈 */
width: 28rpx; .item-play-btn.locked-btn {
height: 28rpx; background: #F0F0F0;
border-color: #E0E0E0;
opacity: 0.6;
} }
.item-play-btn.active .item-play-icon { .lock-icon {
filter: brightness(0) invert(1); font-size: 28rpx;
}
/* 纯文字播放三角 */
.play-tri {
font-size: 22rpx;
color: #888;
padding-left: 4rpx;
}
.item-play-btn.active .play-tri {
color: #FFF;
}
/* 迷你暂停两条竖线 */
.mini-pause {
display: flex;
gap: 6rpx;
align-items: center;
}
.mp-bar {
width: 5rpx;
height: 26rpx;
background: #FFF;
border-radius: 3rpx;
}
/* 全锁定提示条(橙色调) */
.locked-notice {
background: rgba(255, 157, 66, 0.06);
border-color: rgba(255, 157, 66, 0.25);
} }
+25 -12
View File
@@ -10,6 +10,7 @@ const api = require('../../utils/api')
Page({ Page({
data: { data: {
isVip: false, isVip: false,
vipPriceText: '',
categories: [], categories: [],
activeFilter: '', activeFilter: '',
filteredDomains: [], filteredDomains: [],
@@ -22,6 +23,7 @@ Page({
onShow() { onShow() {
this._loadChannels() this._loadChannels()
this._loadVipPrice()
this._onSubChange = () => this._loadChannels() this._onSubChange = () => this._loadChannels()
this._onVipChange = () => this._loadChannels() this._onVipChange = () => this._loadChannels()
app.on('subscriptionChange', this._onSubChange) app.on('subscriptionChange', this._onSubChange)
@@ -69,6 +71,9 @@ Page({
var filtered = channels.map(function (ch) { var filtered = channels.map(function (ch) {
var isFree = ch.isFree === 1 var isFree = ch.isFree === 1
var isVipOnly = ch.isVipOnly === 1 var isVipOnly = ch.isVipOnly === 1
var isSubscribed = ch.hasSubscribed === 1
// VIP 用户可播放所有频道
var canPlay = gd.isVip || isFree || isSubscribed
// 最低价(分→元) // 最低价(分→元)
var lowestPrice = null var lowestPrice = null
if (!isFree && !isVipOnly) { if (!isFree && !isVipOnly) {
@@ -83,10 +88,11 @@ Page({
} }
} }
return Object.assign({}, ch, { return Object.assign({}, ch, {
_isSubscribed: ch.hasSubscribed === 1, _isSubscribed: isSubscribed,
_isFree: isFree, _isFree: isFree,
_isVipOnly: isVipOnly, _isVipOnly: isVipOnly,
_lowestPrice: lowestPrice _lowestPrice: lowestPrice,
_canPlay: canPlay
}) })
}) })
@@ -102,6 +108,19 @@ Page({
}) })
}, },
_loadVipPrice() {
var self = this
api.getVipConfig().then(function (res) {
if (res.code === 200 && res.data) {
var cfg = res.data
var p = cfg.discountedPrice > 0 ? cfg.discountedPrice : cfg.price
self.setData({ vipPriceText: (p / 100).toFixed(2) + '元' })
}
}).catch(function () {
self.setData({ vipPriceText: '' })
})
},
/** /**
* 切换分类筛选 * 切换分类筛选
*/ */
@@ -129,25 +148,19 @@ Page({
} }
if (!channel) return if (!channel) return
// 已订阅 → 直接进详情 // 规则1canPlayVIP || isFree || hasSubscribed)→ 进频道详情
if (channel._isSubscribed) { if (channel._canPlay) {
wx.navigateTo({ url: '/pages/channel-detail/index?id=' + id }) wx.navigateTo({ url: '/pages/channel-detail/index?id=' + id })
return return
} }
// 免费 → 直接进详情收听 // 规则2:VIP专享 → 只能开通VIP,不可订阅
if (channel._isFree) {
wx.navigateTo({ url: '/pages/channel-detail/index?id=' + id })
return
}
// VIP专享 → 引导 VIP 页
if (channel._isVipOnly) { if (channel._isVipOnly) {
wx.navigateTo({ url: '/pages/vip/index' }) wx.navigateTo({ url: '/pages/vip/index' })
return return
} }
// 付费订阅 → 跳转 VIP/订阅页(channel 模式) // 付费频道未订阅 → 跳转订阅页
var params = 'channelId=' + id var params = 'channelId=' + id
+ '&channelName=' + encodeURIComponent(channel.name || '') + '&channelName=' + encodeURIComponent(channel.name || '')
+ '&monthlyPrice=' + (channel.monthlyPrice || 0) + '&monthlyPrice=' + (channel.monthlyPrice || 0)
+5 -13
View File
@@ -58,26 +58,18 @@
</view> </view>
<!-- 行动按钮 --> <!-- 行动按钮 -->
<!-- 已订阅 --> <!-- 可播放(VIP / 免费 / 已订阅)→ 收听 -->
<view wx:if="{{item._isSubscribed}}" class="sub-btn subscribed" bindtap="onAction" data-id="{{item.id}}"> <view wx:if="{{item._canPlay}}" class="sub-btn free" bindtap="onAction" data-id="{{item.id}}">
<t-icon name="check-circle" size="28rpx" color="#999" /> <text>▶ 收听</text>
<text>已订阅</text>
</view> </view>
<!-- 免费 → 收听 --> <!-- VIP专享(且非VIP用户)-->
<view wx:elif="{{item._isFree}}" class="sub-btn free" bindtap="onAction" data-id="{{item.id}}">
<t-icon name="play-circle" size="28rpx" color="#FFF" />
<text>收听</text>
</view>
<!-- VIP专享 -->
<view wx:elif="{{item._isVipOnly}}" class="sub-btn vip" bindtap="onAction" data-id="{{item.id}}"> <view wx:elif="{{item._isVipOnly}}" class="sub-btn vip" bindtap="onAction" data-id="{{item.id}}">
<text>👑 VIP专享</text> <text>👑 VIP专享</text>
</view> </view>
<!-- 付费订阅 --> <!-- 付费订阅 -->
<view wx:else class="sub-btn paid" bindtap="onAction" data-id="{{item.id}}"> <view wx:else class="sub-btn paid" bindtap="onAction" data-id="{{item.id}}">
<t-icon name="shop" size="28rpx" color="#FFF" />
<text>订阅</text> <text>订阅</text>
</view> </view>
</view> </view>
@@ -94,7 +86,7 @@
<text class="vip-banner-desc">立享极致畅听体验</text> <text class="vip-banner-desc">立享极致畅听体验</text>
</view> </view>
<view class="vip-banner-price"> <view class="vip-banner-price">
<text>19.9元/月</text> <text>{{vipPriceText || '会员价'}}</text>
</view> </view>
</view> </view>
+163 -47
View File
@@ -1,5 +1,5 @@
/** /**
* 收听历史 — 从后端获取历史列表 * 收听历史 — 历史 / 收藏 两 Tab
*/ */
const app = getApp() const app = getApp()
const api = require('../../utils/api') const api = require('../../utils/api')
@@ -7,15 +7,28 @@ const util = require('../../utils/util')
Page({ Page({
data: { data: {
filter: 'all', tab: 'history', // 'history' | 'favorite'
cleared: false,
historyList: [], historyList: [],
isPlaying: false, isPlaying: false,
loading: true loading: true
}, },
onShow() { onShow() {
this._refresh() const self = this
const gd = app.globalData
// 未登录则先登录
if (!gd.isLoggedIn || !gd.token) {
app.login().then(function () {
self._refresh()
}).catch(function () {
self.setData({ loading: false })
wx.showToast({ title: '请先登录', icon: 'none' })
})
} else {
this._refresh()
}
this._onPlayerChange = () => this._updatePlayState() this._onPlayerChange = () => this._updatePlayState()
app.on('playerStateChange', this._onPlayerChange) app.on('playerStateChange', this._onPlayerChange)
}, },
@@ -24,35 +37,52 @@ Page({
if (this._onPlayerChange) app.off('playerStateChange', this._onPlayerChange) if (this._onPlayerChange) app.off('playerStateChange', this._onPlayerChange)
}, },
_refresh() { setTab(e) {
if (this.data.cleared) return const tab = e.currentTarget.dataset.val
if (tab === this.data.tab) return
this.setData({ tab, historyList: [], loading: true })
this._refresh()
},
_refresh() {
if (this.data.tab === 'history') {
this._loadHistory()
} else {
this._loadFavorites()
}
},
_loadHistory() {
const self = this const self = this
const gd = app.globalData const gd = app.globalData
api.getHistoryList({ current: 1, pageSize: 30 }).then(function (res) { api.getHistoryList({ current: 1, pageSize: 30 }).then(function (res) {
if (res.code === 200 && res.data) { if (res.code === 200 && res.data) {
var list = res.data.list || res.data || [] var list = (res.data.list || []).map(function (item) {
// program 嵌套在 item.program 下
// 附带格式化信息 var program = item.program || {}
list = list.map(function (item) { // channel 可能为 nullchannel cover 在 program.cover
// 节目可能包含频道信息,根据后端返回结构适配 var channel = program.channel || {}
var channel = item.channel || item.program && item.program.channel || {} return {
var program = item.program || item // 播放所需字段
id: program.id,
return Object.assign({}, program, { title: program.title || '未知节目',
_domainName: channel.name || program.channelName || '', channelId: program.channelId || '',
_icon: channel.icon || '🎵', content: program.content || '',
_bgColor: channel.bgColor || '#F0F0F0', audioId: program.audioId || '',
_coverUrl: (channel.cover && channel.cover.url) || channel.coverUrl || '', // 用历史记录的 durationprogram.duration 可能是 0
_friendlyDate: util.getFriendlyDate( duration: item.duration || program.duration || 0,
program.createdAt ? program.createdAt.substring(0, 10) : '' // 显示字段
), _domainName: channel.name || '',
durationText: util.formatTime(program.duration || 0), _icon: program.cover || channel.cover || '📻',
_bgColor: '#FFE8CC',
_friendlyDate: util.getFriendlyDate(item.createdAtStr ? item.createdAtStr.substring(0, 10) : ''),
durationText: util.formatTime(item.duration || 0),
// 播放进度
_progress: item.progress || 0,
_isThisPlaying: gd.activeContent && gd.activeContent.id === program.id _isThisPlaying: gd.activeContent && gd.activeContent.id === program.id
}) }
}) })
self.setData({ historyList: list, isPlaying: gd.isPlaying, loading: false }) self.setData({ historyList: list, isPlaying: gd.isPlaying, loading: false })
} else { } else {
self.setData({ historyList: [], loading: false }) self.setData({ historyList: [], loading: false })
@@ -63,9 +93,40 @@ Page({
}) })
}, },
/** _loadFavorites() {
* 仅更新播放状态 const self = this
*/ const gd = app.globalData
api.getFavoriteList({ current: 1, pageSize: 30 }).then(function (res) {
if (res.code === 200 && res.data) {
var list = (res.data.list || []).map(function (item) {
var program = item.program || item
var channel = program.channel || {}
return {
id: program.id,
title: program.title || '未知节目',
channelId: program.channelId || '',
content: program.content || '',
audioId: program.audioId || '',
duration: program.duration || 0,
_domainName: channel.name || '',
_icon: program.cover || channel.cover || '📻',
_bgColor: '#FFE8CC',
_friendlyDate: util.getFriendlyDate(item.createdAtStr ? item.createdAtStr.substring(0, 10) : ''),
durationText: util.formatTime(program.duration || 0),
_isThisPlaying: gd.activeContent && gd.activeContent.id === program.id
}
})
self.setData({ historyList: list, isPlaying: gd.isPlaying, loading: false })
} else {
self.setData({ historyList: [], loading: false })
}
}).catch(function (err) {
console.error('[History] 加载收藏失败:', err)
self.setData({ loading: false })
})
},
_updatePlayState() { _updatePlayState() {
var gd = app.globalData var gd = app.globalData
var list = this.data.historyList.map(function (item) { var list = this.data.historyList.map(function (item) {
@@ -76,41 +137,96 @@ Page({
this.setData({ historyList: list, isPlaying: gd.isPlaying }) this.setData({ historyList: list, isPlaying: gd.isPlaying })
}, },
setFilter(e) {
this.setData({ filter: e.currentTarget.dataset.val })
},
onPlay(e) { onPlay(e) {
const id = e.currentTarget.dataset.id const id = e.currentTarget.dataset.id
const gd = app.globalData const gd = app.globalData
// 从已加载数据中查找 if (gd.activeContent && gd.activeContent.id === id) {
var content = null app.togglePlay()
return
}
// 从列表中找到基础信息
var base = null
for (var i = 0; i < this.data.historyList.length; i++) { for (var i = 0; i < this.data.historyList.length; i++) {
if (this.data.historyList[i].id === id) { if (this.data.historyList[i].id === id) {
content = this.data.historyList[i] base = this.data.historyList[i]
break break
} }
} }
if (!content) return if (!base) return
if (gd.activeContent && gd.activeContent.id === id) { // 拉取完整节目详情(含 audio.url)再播放
app.togglePlay() wx.showLoading({ title: '加载中' })
} else { api.getProgramDetail(id).then(function (res) {
app.playContent(content) wx.hideLoading()
} if (res.code === 200 && res.data) {
app.playContent(Object.assign({}, base, res.data))
} else {
wx.showToast({ title: '加载失败', icon: 'none' })
}
}).catch(function () {
wx.hideLoading()
wx.showToast({ title: '网络异常', icon: 'none' })
})
},
onDeleteItem(e) {
const id = e.currentTarget.dataset.id
const tab = this.data.tab
const self = this
const label = tab === 'history' ? '历史' : '收藏'
wx.showModal({
title: '删除' + label,
content: '确定删除这条' + label + '吗?',
success(res) {
if (!res.confirm) return
const fn = tab === 'history'
? api.deleteHistory(id)
: api.removeFavorite(id)
fn.then(function (r) {
if (r.code === 200) {
// 本地即时移除
var list = self.data.historyList.filter(function (item) { return item.id !== id })
self.setData({ historyList: list })
} else {
wx.showToast({ title: r.msg || '删除失败', icon: 'none' })
}
}).catch(function () {
wx.showToast({ title: '网络异常', icon: 'none' })
})
}
})
}, },
onClear() { onClear() {
const self = this const self = this
const tab = this.data.tab
const label = tab === 'history' ? '收听历史' : '全部收藏'
wx.showModal({ wx.showModal({
title: '提示', title: '清空' + label,
content: '确定要清空所有收听历史吗?', content: '确定要清空所有' + label + '吗?',
success(res) { success(res) {
if (res.confirm) { if (!res.confirm) return
self.setData({ cleared: true, historyList: [] }) const fn = tab === 'history'
} ? api.deleteAllHistory()
: api.removeAllFavorites()
fn.then(function (r) {
if (r.code === 200) {
self.setData({ historyList: [] })
} else {
wx.showToast({ title: r.msg || '操作失败', icon: 'none' })
}
}).catch(function () {
wx.showToast({ title: '网络异常', icon: 'none' })
})
} }
}) })
} },
goDiscover() { wx.switchTab({ url: '/pages/discover/index' }) },
goHome() { wx.switchTab({ url: '/pages/index/index' }) },
goVip() { wx.navigateTo({ url: '/pages/vip/index' }) }
}) })
+104 -35
View File
@@ -1,66 +1,135 @@
<!-- 收听历史 —— 按日期倒序的已听列表 --> <!-- 收听历史 / 收藏 -->
<view class="history-page"> <view class="history-page">
<!-- 筛选Tab + 清空按钮 --> <!-- Tab 切换 + 清空 -->
<view class="filter-header"> <view class="filter-header">
<view class="filter-tabs"> <view class="filter-tabs">
<text <view class="tab-item {{tab === 'history' ? 'active' : ''}}" bindtap="setTab" data-val="history">
class="tab {{filter === 'all' ? 'active' : ''}}" <text class="tab-text">历史</text>
bindtap="setFilter" <view class="tab-underline"></view>
data-val="all" </view>
>全部片段</text> <view class="tab-item {{tab === 'favorite' ? 'active' : ''}}" bindtap="setTab" data-val="favorite">
<text <text class="tab-text">收藏</text>
class="tab {{filter === 'subscribed' ? 'active' : ''}}" <view class="tab-underline"></view>
bindtap="setFilter" </view>
data-val="subscribed"
>仅看已订阅</text>
</view> </view>
<view class="clear-btn tap-active" bindtap="onClear"> <view class="clear-btn tap-active" bindtap="onClear" wx:if="{{historyList.length > 0}}">
<text class="clear-icon">🗑</text> <view class="icon-trash">
<view class="trash-handle"></view>
<view class="trash-lid-bar"></view>
<view class="trash-body">
<view class="trash-stripe"></view>
<view class="trash-stripe"></view>
<view class="trash-stripe"></view>
</view>
</view>
<text class="clear-text">清空</text> <text class="clear-text">清空</text>
</view> </view>
</view> </view>
<!-- 历史列表 --> <!-- 列表 -->
<view class="list-area"> <view class="list-area">
<!-- 空状态 --> <!-- 加载骨架 -->
<view wx:if="{{cleared || historyList.length === 0}}" class="empty-state"> <view wx:if="{{loading}}" class="skeleton-wrap">
<view class="empty-icon-wrap"> <view wx:for="{{[1,2,3]}}" wx:key="*this" class="skeleton-item">
<text class="empty-emoji">📭</text> <view class="sk-icon"></view>
<view class="sk-lines">
<view class="sk-line sk-line-short"></view>
<view class="sk-line sk-line-long"></view>
<view class="sk-line sk-line-mid"></view>
</view>
</view> </view>
<text class="empty-text">暂无收听历史</text>
</view> </view>
<!-- 历史条目 --> <!-- 历史空状态 -->
<view wx:elif="{{historyList.length === 0 && tab === 'history'}}" class="empty-state">
<text class="empty-icon">🎧</text>
<text class="empty-title">还没有收听记录</text>
<text class="empty-desc">去发现频道,开始你的第一段收听</text>
<view class="empty-actions">
<view class="btn-primary tap-active" bindtap="goDiscover">
<text class="btn-text">去发现频道</text>
</view>
<view class="btn-ghost tap-active" bindtap="goHome">
<text class="btn-ghost-text">回到首页</text>
</view>
</view>
</view>
<!-- 收藏空状态 -->
<view wx:elif="{{historyList.length === 0 && tab === 'favorite'}}" class="empty-state">
<text class="empty-icon">🔖</text>
<text class="empty-title">还没有收藏内容</text>
<text class="empty-desc">听到喜欢的节目,点击 ♡ 收藏它</text>
<view class="empty-actions">
<view class="btn-primary tap-active" bindtap="goDiscover">
<text class="btn-text">订阅感兴趣的频道</text>
</view>
</view>
<!-- 推荐订阅提示 -->
<view class="upsell-card">
<text class="upsell-icon">👑</text>
<view class="upsell-body">
<text class="upsell-title">开通会员</text>
<text class="upsell-desc">解锁全部付费频道,无限收藏</text>
</view>
<view class="upsell-btn tap-active" bindtap="goVip">
<text class="upsell-btn-text">了解</text>
</view>
</view>
</view>
<!-- 条目列表 -->
<view <view
wx:for="{{historyList}}" wx:for="{{historyList}}"
wx:key="id" wx:key="id"
class="history-item card" class="history-item"
bindtap="onPlay" bindtap="onPlay"
data-id="{{item.id}}" data-id="{{item.id}}"
> >
<!-- 频道图标 --> <!-- 频道/节目图标 -->
<view class="h-icon" style="background: {{item._bgColor}};"> <view class="h-icon" style="background: {{item._bgColor}};">
<image wx:if="{{item._coverUrl}}" src="{{item._coverUrl}}" class="h-cover-img" mode="aspectFill" /> <text class="h-emoji">{{item._icon}}</text>
<text wx:else class="h-emoji">{{item._icon}}</text>
</view> </view>
<!-- 信息 --> <!-- 文字信息 -->
<view class="h-info"> <view class="h-info">
<text class="h-channel">{{item._domainName}}</text> <text class="h-channel" wx:if="{{item._domainName}}">{{item._domainName}}</text>
<text class="h-title {{item._isThisPlaying ? 'text-primary' : ''}}">{{item.title}}</text> <text class="h-title {{item._isThisPlaying ? 'text-primary' : ''}}">{{item.title}}</text>
<text class="h-meta">{{item._friendlyDate}} · {{item.durationText}}</text> <view class="h-meta-row">
<text class="h-meta">{{item._friendlyDate}}</text>
<text class="h-meta" wx:if="{{item.durationText}}"> · {{item.durationText}}</text>
</view>
<!-- 播放进度条 -->
<view class="h-progress-wrap" wx:if="{{item._progress > 0 && item.duration > 0}}">
<view class="h-progress-bar" style="width: {{item._progress / item.duration * 100}}%;"></view>
</view>
</view> </view>
<!-- 播放指示 --> <!-- 播放指示 / 播放按钮 -->
<view wx:if="{{item._isThisPlaying && isPlaying}}" class="playing-indicator"> <view class="h-right">
<view class="bar bar-1"></view> <view wx:if="{{item._isThisPlaying && isPlaying}}" class="playing-indicator">
<view class="bar bar-2"></view> <view class="bar bar-1"></view>
<view class="bar bar-3"></view> <view class="bar bar-2"></view>
<view class="bar bar-3"></view>
</view>
<view wx:else class="play-mini">
<text class="play-mini-icon">▶</text>
</view>
</view> </view>
<view wx:else class="play-mini">
<image src="/assets/icons/play.svg" class="play-mini-icon" /> <!-- 删除按钮 -->
<view class="h-del tap-active" catchtap="onDeleteItem" data-id="{{item.id}}">
<view class="icon-trash icon-trash-sm">
<view class="trash-handle"></view>
<view class="trash-lid-bar"></view>
<view class="trash-body">
<view class="trash-stripe"></view>
<view class="trash-stripe"></view>
<view class="trash-stripe"></view>
</view>
</view>
</view> </view>
</view> </view>
+296 -71
View File
@@ -1,106 +1,239 @@
/* 收听历史样式 */ /* 收听历史 / 收藏 */
.history-page { .history-page {
min-height: 100vh; min-height: 100vh;
background: var(--color-bg-page); background: #F7F3EE;
} }
/* 筛选头部 */ /* ── Tab 头部 ── */
.filter-header { .filter-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 16rpx 32rpx; padding: 0 32rpx;
background: #FFFFFF; background: #FFFFFF;
border-bottom: 1rpx solid #F5F5F5; border-bottom: 1rpx solid #F0EAE2;
} }
.filter-tabs {
display: flex;
gap: 8rpx;
}
.tab-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 24rpx 24rpx 0;
position: relative;
}
.tab-text {
font-size: 28rpx;
font-weight: 600;
color: #AAA;
padding-bottom: 18rpx;
font-family: 'PingFang SC', sans-serif;
transition: color 0.2s;
}
.tab-item.active .tab-text {
color: #2C1A08;
}
.tab-underline {
height: 4rpx;
width: 0;
background: #FF9D42;
border-radius: 2rpx;
transition: width 0.25s;
}
.tab-item.active .tab-underline {
width: 100%;
}
/* 清空按钮 */
.clear-btn { .clear-btn {
display: flex; display: flex;
align-items: center; align-items: center;
} gap: 8rpx;
.clear-icon { padding: 12rpx 0;
font-size: 22rpx;
margin-right: 6rpx;
} }
.clear-text { .clear-text {
font-size: 22rpx; font-size: 24rpx;
color: #999; color: #BBBBBB;
font-weight: 500; font-weight: 500;
} }
/* 筛选Tab */ /* ── 列表区 ── */
.filter-tabs {
display: flex;
gap: 32rpx;
}
.tab {
font-size: 26rpx;
font-weight: 700;
color: #999;
padding-bottom: 12rpx;
border-bottom: 4rpx solid transparent;
transition: all 0.2s;
}
.tab.active {
color: #333;
border-bottom-color: var(--color-primary);
}
/* 列表 */
.list-area { .list-area {
padding: 20rpx 32rpx; padding: 24rpx 28rpx;
} }
/* 空状态 */ /* ── 骨架屏 ── */
.skeleton-wrap { display: flex; flex-direction: column; gap: 20rpx; }
.skeleton-item {
display: flex;
align-items: center;
gap: 24rpx;
background: #FFFFFF;
border-radius: 20rpx;
padding: 24rpx;
}
.sk-icon {
width: 88rpx;
height: 88rpx;
border-radius: 20rpx;
background: linear-gradient(90deg, #F0EAE2 0%, #E8DFD5 50%, #F0EAE2 100%);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
flex-shrink: 0;
}
.sk-lines {
flex: 1;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.sk-line {
height: 18rpx;
background: linear-gradient(90deg, #F0EAE2 0%, #E8DFD5 50%, #F0EAE2 100%);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
border-radius: 9rpx;
}
.sk-line-short { width: 40%; }
.sk-line-long { width: 80%; }
.sk-line-mid { width: 55%; }
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* ── 空状态 ── */
.empty-state { .empty-state {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding: 160rpx 0; padding: 80rpx 48rpx 48rpx;
opacity: 0.5;
} }
.empty-icon-wrap { .empty-icon {
width: 128rpx; font-size: 96rpx;
height: 128rpx; margin-bottom: 32rpx;
border-radius: 50%; filter: drop-shadow(0 8rpx 16rpx rgba(255,157,66,0.2));
background: #F5F5F5; }
.empty-title {
font-size: 36rpx;
font-weight: 700;
color: #2C1A08;
margin-bottom: 16rpx;
font-family: 'PingFang SC', sans-serif;
}
.empty-desc {
font-size: 26rpx;
color: #B0A090;
text-align: center;
line-height: 1.6;
margin-bottom: 48rpx;
}
.empty-actions {
display: flex;
flex-direction: column;
gap: 20rpx;
width: 100%;
max-width: 480rpx;
}
.btn-primary {
background: linear-gradient(135deg, #FF9D42, #E07020);
border-radius: 48rpx;
padding: 28rpx 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-bottom: 24rpx; box-shadow: 0 8rpx 24rpx rgba(255,157,66,0.35);
} }
.empty-emoji { .btn-text {
font-size: 48rpx; font-size: 30rpx;
font-weight: 700;
color: #FFFFFF;
letter-spacing: 1rpx;
} }
.empty-text { .btn-ghost {
border: 2rpx solid #E8DFD5;
border-radius: 48rpx;
padding: 26rpx 0;
display: flex;
align-items: center;
justify-content: center;
background: #FFFFFF;
}
.btn-ghost-text {
font-size: 28rpx; font-size: 28rpx;
color: #999; font-weight: 600;
font-weight: 500; color: #8C7B6A;
} }
/* 历史条目 */ /* VIP upsell 卡片 */
.upsell-card {
display: flex;
align-items: center;
gap: 20rpx;
background: linear-gradient(135deg, #FFF8EE, #FFF3E0);
border: 1rpx solid #FFDFA0;
border-radius: 20rpx;
padding: 24rpx 28rpx;
margin-top: 40rpx;
width: 100%;
max-width: 480rpx;
box-sizing: border-box;
}
.upsell-icon { font-size: 48rpx; }
.upsell-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rpx;
}
.upsell-title {
font-size: 28rpx;
font-weight: 700;
color: #C07000;
}
.upsell-desc {
font-size: 22rpx;
color: #C09040;
}
.upsell-btn {
background: #FF9D42;
border-radius: 28rpx;
padding: 12rpx 28rpx;
}
.upsell-btn-text {
font-size: 24rpx;
font-weight: 700;
color: #FFF;
}
/* ── 历史条目 ── */
.history-item { .history-item {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 24rpx; padding: 24rpx;
margin-bottom: 20rpx; margin-bottom: 16rpx;
background: #FFFFFF;
border-radius: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(44,26,8,0.04);
} }
.history-item:active { .history-item:active {
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.06); box-shadow: 0 4rpx 20rpx rgba(44,26,8,0.08);
transform: scale(0.99);
} }
.h-icon { .h-icon {
width: 88rpx; width: 88rpx;
height: 88rpx; height: 88rpx;
border-radius: 24rpx; border-radius: 20rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
} }
.h-emoji { .h-emoji { font-size: 42rpx; }
font-size: 40rpx;
}
.h-info { .h-info {
flex: 1; flex: 1;
@@ -111,50 +244,73 @@
display: block; display: block;
font-size: 20rpx; font-size: 20rpx;
font-weight: 700; font-weight: 700;
color: #999; color: #FF9D42;
letter-spacing: 2rpx; letter-spacing: 1rpx;
margin-bottom: 6rpx; margin-bottom: 6rpx;
text-transform: uppercase;
} }
.h-title { .h-title {
display: block; display: block;
font-size: 26rpx; font-size: 28rpx;
font-weight: 700; font-weight: 700;
color: #333; color: #2C1A08;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-family: 'PingFang SC', sans-serif;
} }
.h-title.text-primary { .h-title.text-primary { color: #FF9D42; }
color: var(--color-primary); .h-meta-row {
display: flex;
align-items: center;
flex-wrap: wrap;
margin-top: 8rpx;
} }
.h-meta { .h-meta {
display: block; display: inline;
font-size: 20rpx; font-size: 22rpx;
color: #BBB; color: #C8BAAA;
margin-top: 6rpx;
font-weight: 500; font-weight: 500;
} }
/* 播放进度条 */
.h-progress-wrap {
width: 100%;
height: 4rpx;
background: #F0EAE2;
border-radius: 2rpx;
margin-top: 12rpx;
overflow: hidden;
}
.h-progress-bar {
height: 100%;
background: linear-gradient(90deg, #FF9D42, #E07020);
border-radius: 2rpx;
max-width: 100%;
}
.h-right {
flex-shrink: 0;
}
/* 播放指示器 */ /* 播放指示器 */
.playing-indicator { .playing-indicator {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
gap: 4rpx; gap: 4rpx;
width: 48rpx; width: 40rpx;
height: 48rpx; height: 40rpx;
justify-content: center; justify-content: center;
flex-shrink: 0; padding-bottom: 4rpx;
} }
.bar { .bar {
width: 6rpx; width: 6rpx;
border-radius: 4rpx; border-radius: 4rpx;
background: var(--color-primary); background: #FF9D42;
animation: bounce 0.6s ease-in-out infinite; animation: bounce 0.6s ease-in-out infinite;
} }
.bar-1 { height: 24rpx; animation-delay: 0s; } .bar-1 { height: 24rpx; animation-delay: 0s; }
.bar-2 { height: 40rpx; animation-delay: 0.1s; } .bar-2 { height: 40rpx; animation-delay: 0.1s; }
.bar-3 { height: 16rpx; animation-delay: 0.2s; } .bar-3 { height: 16rpx; animation-delay: 0.2s; }
@keyframes bounce { @keyframes bounce {
0%, 100% { transform: scaleY(0.4); } 0%, 100% { transform: scaleY(0.4); }
50% { transform: scaleY(1); } 50% { transform: scaleY(1); }
@@ -164,14 +320,83 @@
width: 56rpx; width: 56rpx;
height: 56rpx; height: 56rpx;
border-radius: 50%; border-radius: 50%;
border: 2rpx solid #EEE; background: #FFF4E8;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
} }
.play-mini-icon { .play-mini-icon {
width: 20rpx; font-size: 22rpx;
height: 20rpx; color: #FF9D42;
opacity: 0.4; padding-left: 4rpx;
}
/* 单条删除按钮 */
.h-del {
width: 52rpx;
height: 52rpx;
display: flex;
align-items: center;
justify-content: center;
margin-left: 4rpx;
flex-shrink: 0;
}
/* ── 纯 view 垃圾桶图标 ── */
.icon-trash {
display: flex;
flex-direction: column;
align-items: center;
gap: 3rpx;
width: 28rpx;
padding-top: 6rpx;
position: relative;
}
.trash-handle {
width: 12rpx;
height: 6rpx;
border: 3rpx solid #BBBBBB;
border-bottom: none;
border-radius: 4rpx 4rpx 0 0;
box-sizing: border-box;
margin-bottom: 2rpx;
}
.trash-lid-bar {
width: 28rpx;
height: 4rpx;
background: #BBBBBB;
border-radius: 2rpx;
}
.trash-body {
width: 22rpx;
height: 24rpx;
border: 3rpx solid #BBBBBB;
border-top: none;
border-radius: 0 0 5rpx 5rpx;
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: center;
gap: 4rpx;
padding: 4rpx 3rpx 3rpx;
}
.trash-stripe {
flex: 1;
background: #BBBBBB;
border-radius: 2rpx;
max-width: 2rpx;
}
/* 行内小号 */
.icon-trash-sm .trash-handle,
.icon-trash-sm .trash-lid-bar,
.icon-trash-sm .trash-stripe {
border-color: #D0C8C0;
background: #D0C8C0;
}
.icon-trash-sm .trash-body {
border-color: #D0C8C0;
background: transparent;
} }
+44 -70
View File
@@ -12,9 +12,36 @@ const app = getApp()
const api = require('../../utils/api') const api = require('../../utils/api')
const util = require('../../utils/util') const util = require('../../utils/util')
/**
* 模块级函数:根据时间段随机返回文案
* 必须在 Page() 外定义,才能在 data 初始化时同步调用,避免闪烁
*/
function _computeGreeting() {
const hour = new Date().getHours()
var pool
if (hour >= 5 && hour < 9) {
pool = ['新的一天,从声音开始', '早起的人,先听一段', '清晨的第一居声,属于你', '晚起不如早起,早起不如听起']
} else if (hour >= 9 && hour < 12) {
pool = ['上午充能量,声音加持', '专注工作,也别忘了呼吸', '高效早上,入耳知识', '好状态,从一段内容开始']
} else if (hour >= 12 && hour < 14) {
pool = ['午后小憩,听点轻松的', '借耳机隔绝喖鼺,中午也有自己的时间', '饭后十分钟,充电一下', '慢下来,下午还长']
} else if (hour >= 14 && hour < 17) {
pool = ['下午了,来点声音撤个困', '下午三点,刚好开始听一段', '下午的阳光和一段好内容', '止止广告,呜口内容']
} else if (hour >= 17 && hour < 19) {
pool = ['日落时分,放慢脚步', '下班了,耳机里换一个频道', '偶尔不刷短视频,试试听点实的', '归途中最适合听一段']
} else if (hour >= 19 && hour < 22) {
pool = ['夜晚温柔,适合倾听', '夜晚的声音,不用起时间', '放下手机,喂口内容', '夹着夜色,听一段值得的']
} else {
pool = ['夜深了,听点帮助入眠的', '出发前最后一段,晚安', '夜深的小时光,留给自己', '好梦将至,晚安']
}
return pool[Math.floor(Math.random() * pool.length)]
}
Page({ Page({
data: { data: {
greetingSub: '', // 同步计算,第一帧就正确,避免闪烁
greetingSub: _computeGreeting(),
statusBarHeight: app.globalData.statusBarHeight || 0,
locationName: '', locationName: '',
weather: null, weather: null,
dateDisplay: '', dateDisplay: '',
@@ -23,7 +50,6 @@ Page({
freeChannels: [], freeChannels: [],
isPlaying: false, isPlaying: false,
isVip: false, isVip: false,
statusBarHeight: 0,
loadingSub: true, loadingSub: true,
loadingFree: true loadingFree: true
}, },
@@ -33,13 +59,13 @@ Page({
onShow() { onShow() {
const gd = app.globalData const gd = app.globalData
this.setData({ this.setData({
greetingSub: this._getGreeting(), greetingSub: _computeGreeting(), // 每次进页随机刷新文案
dateDisplay: util.getDateDisplay(), dateDisplay: util.getDateDisplay(),
weekDay: util.getWeekDay(), weekDay: util.getWeekDay(),
locationName: gd.locationName || '', locationName: gd.locationName || '',
weather: gd.weather || null, weather: gd.weather || null,
isVip: gd.isVip || false, isVip: gd.isVip || false
statusBarHeight: gd.statusBarHeight || 0 // statusBarHeight 小程序运行期间不会变化,无需重设
}) })
this._loadAll() this._loadAll()
this._bindEvents() this._bindEvents()
@@ -96,18 +122,24 @@ Page({
}, },
/** /**
* 拉取免费频道列表 * 拉取频道列表Section 2
* 首次无数据时显示骨架屏,后续静默刷新 * VIP 用户:全部频道;普通用户:仅免费频道
*/ */
_loadFreeChannels() { _loadFreeChannels() {
const self = this const self = this
const gd = app.globalData
const isFirstLoad = self.data.freeChannels.length === 0 const isFirstLoad = self.data.freeChannels.length === 0
if (isFirstLoad) { if (isFirstLoad) {
self.setData({ loadingFree: true }) self.setData({ loadingFree: true })
} }
api.getFreeChannelList({ current: 1, pageSize: 20 }) // VIP 用户加载全量频道,普通用户仅免费频道
var apiCall = gd.isVip
? api.getChannelList({ current: 1, pageSize: 50 })
: api.getFreeChannelList({ current: 1, pageSize: 20 })
apiCall
.then(function (res) { .then(function (res) {
if (res.code !== 200 || !res.data) { if (res.code !== 200 || !res.data) {
self.setData({ freeChannels: [], loadingFree: false }) self.setData({ freeChannels: [], loadingFree: false })
@@ -115,23 +147,21 @@ Page({
} }
const list = res.data.list || [] const list = res.data.list || []
const freeChannels = list.map(function (ch) { const freeChannels = list.map(function (ch) {
return { return {
id: ch.id, id: ch.id,
name: ch.name || '未命名', name: ch.name || '未命名',
// cover 直接是 emoji 字符串
cover: ch.cover || '📻', cover: ch.cover || '📻',
bgColor: self._genColor(ch.id), bgColor: self._genColor(ch.id),
programCount: (ch.Programs || ch.programs || []).length, isFree: ch.isFree,
isFree: ch.isFree isVipOnly: ch.isVipOnly
} }
}) })
self.setData({ freeChannels: freeChannels, loadingFree: false }) self.setData({ freeChannels: freeChannels, loadingFree: false })
}) })
.catch(function (err) { .catch(function (err) {
console.error('[首页] 免费频道失败', err) console.error('[首页] 频道加载失败', err)
self.setData({ loadingFree: false }) self.setData({ loadingFree: false })
}) })
}, },
@@ -261,61 +291,5 @@ Page({
}, },
// ===================== 工具方法 ===================== // ===================== 工具方法 =====================
// _computeGreeting 已提至模块级,可在 data 初始化时直接调用
/** 根据时间段返回问候语 */
_getGreeting() {
const hour = new Date().getHours()
var pool
if (hour >= 5 && hour < 9) {
pool = [
'新的一天,从声音开始',
'早起的人,先听一段',
'清晨的第一居声,属于你',
'晚起不如早起,早起不如听起'
]
} else if (hour >= 9 && hour < 12) {
pool = [
'上午充能量,声音加持',
'专注工作,也别忘了呼吸',
'高效早上,入耳知识',
'好状态,从一段内容开始'
]
} else if (hour >= 12 && hour < 14) {
pool = [
'午后小憩,听点轻松的',
'借耳机隔绝喧嚣,中午也有自己的时间',
'饭后十分钟,充电一下',
'慢下来,下午还长'
]
} else if (hour >= 14 && hour < 17) {
pool = [
'下午了,来点声音撤个困',
'下午三点,刚好开始听一段',
'下午的阳光和一段好内容',
'止止广告,喝口内容'
]
} else if (hour >= 17 && hour < 19) {
pool = [
'日落时分,放慢脚步',
'下班了,耳机里换一个频道',
'偶尔不刷短视频,试试听点实的',
'归途中最适合听一段'
]
} else if (hour >= 19 && hour < 22) {
pool = [
'夜晚温柔,适合倾听',
'夜晚的声音,不用赶时间',
'放下手机,喂口内容',
'夹着夜色,听一段值得的'
]
} else {
pool = [
'夜深了,听点帮助入眠的',
'出发前最后一段,晚安',
'夜奥的小时光,留给自己',
'好梦将至,晚安'
]
}
return pool[Math.floor(Math.random() * pool.length)]
}
}) })
+16 -3
View File
@@ -41,6 +41,16 @@
> >
<view class="content-area"> <view class="content-area">
<!-- VIP 状态卡片(仅 VIP 用户显示) -->
<view wx:if="{{isVip}}" class="vip-home-card tap-active" bindtap="goVip">
<text class="vip-home-icon">👑</text>
<view class="vip-home-body">
<text class="vip-home-title">全部频道会员</text>
<text class="vip-home-desc">所有频道面向你全量开放 · 畅听无限制</text>
</view>
<text class="vip-home-tag">享看权益 </text>
</view>
<!-- ─── Section 1: 我的订阅 ─── --> <!-- ─── Section 1: 我的订阅 ─── -->
<view class="section-header"> <view class="section-header">
<view class="section-title-wrap"> <view class="section-title-wrap">
@@ -152,8 +162,8 @@
<view class="section-header section-header-free"> <view class="section-header section-header-free">
<view class="section-title-wrap"> <view class="section-title-wrap">
<text class="section-dot dot-free"></text> <text class="section-dot dot-free"></text>
<text class="section-title">免费频道</text> <text class="section-title">{{isVip ? '全部频道' : '免费频道'}}</text>
<text class="section-subtitle">无需订阅,随时收听</text> <text class="section-subtitle">{{isVip ? '会员全开放,点即收听' : '无需订阅,随时收听'}}</text>
</view> </view>
<view class="section-action tap-active" bindtap="goDiscover"> <view class="section-action tap-active" bindtap="goDiscover">
<text class="section-action-text">全部</text> <text class="section-action-text">全部</text>
@@ -191,7 +201,10 @@
<text class="free-emoji">{{item.cover || '📻'}}</text> <text class="free-emoji">{{item.cover || '📻'}}</text>
</view> </view>
<text class="free-name">{{item.name}}</text> <text class="free-name">{{item.name}}</text>
<t-tag size="small" variant="light-outline" theme="success">免费</t-tag> <!-- VIP 用户显示频道类型,普通用户只显免费帘记 -->
<t-tag wx:if="{{item.isVipOnly}}" size="small" variant="light" theme="warning">👑</t-tag>
<t-tag wx:elif="{{item.isFree !== 1}}" size="small" variant="light" theme="default">付费</t-tag>
<t-tag wx:else size="small" variant="light-outline" theme="success">免费</t-tag>
</view> </view>
</view> </view>
</scroll-view> </scroll-view>
+33
View File
@@ -118,6 +118,39 @@
.content-area { .content-area {
padding: 24rpx 32rpx 0; padding: 24rpx 32rpx 0;
} }
/* ========== VIP 首页状态卡 ========== */
.vip-home-card {
display: flex;
align-items: center;
gap: 20rpx;
background: linear-gradient(135deg, #FFF8EE, #FFF3DC);
border: 1rpx solid #FFDFA0;
border-radius: 24rpx;
padding: 24rpx 28rpx;
margin-bottom: 32rpx;
}
.vip-home-icon { font-size: 44rpx; }
.vip-home-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rpx;
}
.vip-home-title {
font-size: 28rpx;
font-weight: 700;
color: #B07000;
font-family: 'PingFang SC', sans-serif;
}
.vip-home-desc {
font-size: 22rpx;
color: #C09040;
}
.vip-home-tag {
font-size: 22rpx;
font-weight: 600;
color: #FF9D42;
}
/* ========== Section Header ========== */ /* ========== Section Header ========== */
-64
View File
@@ -1,64 +0,0 @@
/**
* 首次引导页 — 选择2个免费频道
*/
const app = getApp()
const mock = require('../../utils/mock')
Page({
data: {
domains: mock.DOMAINS,
selectedIds: [],
isValid: false,
statusBarHeight: 0
},
onLoad() {
this.setData({
statusBarHeight: app.globalData.statusBarHeight
})
},
/**
* 切换频道选中状态
*/
onToggle(e) {
const id = e.currentTarget.dataset.id
let selected = this.data.selectedIds.slice()
const idx = selected.indexOf(id)
if (idx > -1) {
// 取消选中
selected.splice(idx, 1)
} else {
// 选中(最多2个)
if (selected.length >= 2) {
wx.showToast({ title: '最多选择2个免费频道', icon: 'none' })
return
}
selected.push(id)
}
this.setData({
selectedIds: selected,
isValid: selected.length > 0 && selected.length <= 2
})
},
/**
* 确认选择
*/
onConfirm() {
if (!this.data.isValid) return
const self = this
this.data.selectedIds.forEach(function (id) {
app.subscribeToDomain(id)
})
wx.showToast({ title: '订阅成功!', icon: 'success' })
setTimeout(function () {
wx.switchTab({ url: '/pages/index/index' })
}, 800)
}
})
-6
View File
@@ -1,6 +0,0 @@
{
"usingComponents": {
"t-message": "tdesign-miniprogram/message/message"
},
"navigationStyle": "custom"
}
-50
View File
@@ -1,50 +0,0 @@
<!-- 首次引导页 —— 选择2个免费频道 -->
<view class="onboarding-page">
<!-- 顶部区域 -->
<view class="header" style="padding-top: {{statusBarHeight + 10}}px;">
<text class="header-title">选择感兴趣的频道</text>
<view class="header-sub">
<text class="sub-text">可免费选择 2 个</text>
<text class="sub-warn">选择后不可更换</text>
</view>
</view>
<!-- 频道网格 -->
<scroll-view scroll-y enhanced show-scrollbar="{{false}}" class="grid-scroll">
<view class="grid">
<view
wx:for="{{domains}}"
wx:key="id"
class="grid-item {{selectedIds.indexOf(item.id) > -1 ? 'selected' : ''}}"
bindtap="onToggle"
data-id="{{item.id}}"
>
<!-- 图标 -->
<view class="item-icon" style="background: {{item.bgColor}};">
<text class="icon-emoji">{{item.icon}}</text>
</view>
<!-- 名称 -->
<text class="item-name">{{item.name}}</text>
<text class="item-tag">{{item.tag}}</text>
<!-- 选中对勾 -->
<view class="check-mark" wx:if="{{selectedIds.indexOf(item.id) > -1}}">
<text class="check-icon">✓</text>
</view>
</view>
</view>
</scroll-view>
<!-- 底部确认按钮 -->
<view class="footer">
<button
class="confirm-btn {{isValid ? 'active' : 'disabled'}}"
bindtap="onConfirm"
disabled="{{!isValid}}"
>
确认选择 {{selectedIds.length > 0 ? '(' + selectedIds.length + '/2)' : ''}}
</button>
</view>
<t-message id="t-message" />
</view>
-170
View File
@@ -1,170 +0,0 @@
/* 首次引导页样式 */
.onboarding-page {
height: 100vh;
display: flex;
flex-direction: column;
background: #FFFFFF;
}
/* 顶部 */
.header {
padding: 24rpx 40rpx 24rpx;
position: sticky;
top: 0;
z-index: 10;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-bottom: 1rpx solid #F5F5F5;
}
.header-title {
font-size: 44rpx;
font-weight: 700;
color: #333;
display: block;
}
.header-sub {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16rpx;
}
.sub-text {
font-size: 26rpx;
color: #999;
}
.sub-warn {
font-size: 22rpx;
color: var(--color-primary);
}
/* 网格滚动 */
.grid-scroll {
flex: 1;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.grid-scroll::-webkit-scrollbar {
display: none;
width: 0 !important;
height: 0 !important;
}
.grid {
display: flex;
flex-wrap: wrap;
padding: 24rpx 20rpx 200rpx;
gap: 16rpx;
}
/* 网格项 */
.grid-item {
width: calc(33.33% - 12rpx);
box-sizing: border-box;
background: #F8F8F8;
border-radius: 32rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 28rpx 16rpx;
position: relative;
border: 4rpx solid transparent;
transition: all 0.3s ease;
}
.grid-item.selected {
border-color: var(--color-primary);
background: var(--color-primary-light);
box-shadow: 0 8rpx 24rpx rgba(255, 157, 66, 0.15);
transform: scale(1.02);
}
/* 图标 */
.item-icon {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16rpx;
}
.icon-emoji {
font-size: 40rpx;
}
/* 文字 */
.item-name {
font-size: 24rpx;
font-weight: 700;
color: #333;
text-align: center;
line-height: 1.3;
margin-bottom: 6rpx;
}
.item-tag {
font-size: 18rpx;
color: #999;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
/* 选中对勾 */
.check-mark {
position: absolute;
top: 12rpx;
right: 12rpx;
width: 40rpx;
height: 40rpx;
border-radius: 50%;
background: var(--color-primary);
display: flex;
align-items: center;
justify-content: center;
}
.check-icon {
font-size: 22rpx;
color: #FFF;
font-weight: 700;
}
/* 底部 */
.footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 24rpx 40rpx;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
background: linear-gradient(to top, #FFFFFF, #FFFFFF, rgba(255,255,255,0));
}
.confirm-btn {
width: 100%;
height: 100rpx;
border-radius: 999rpx;
font-size: 32rpx;
font-weight: 600;
border: none;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.confirm-btn.active {
background: var(--color-primary);
color: #FFF;
box-shadow: 0 12rpx 32rpx rgba(255, 157, 66, 0.3);
}
.confirm-btn.active:active {
transform: scale(0.97);
}
.confirm-btn.disabled {
background: #E5E5E5;
color: #BBB;
}
.confirm-btn::after {
border: none;
}
+115 -15
View File
@@ -13,6 +13,7 @@ Page({
activeContent: null, activeContent: null,
isPlaying: false, isPlaying: false,
isVip: false, isVip: false,
isLiked: false,
currentTime: 0, currentTime: 0,
duration: 0, duration: 0,
currentTimeText: '00:00', currentTimeText: '00:00',
@@ -20,16 +21,13 @@ Page({
displayDate: '', displayDate: '',
playbackRate: 1.0, playbackRate: 1.0,
statusBarHeight: 0, statusBarHeight: 0,
showTranscript: false, // 封面 ⇔ 文案切换 showTranscript: false,
showSpeedSheet: false, // 评论弹层
speedItems: [ showComments: false,
{ label: '0.5x' }, commentList: [],
{ label: '0.75x' }, commentText: '',
{ label: '1.0x' }, commentLoading: false,
{ label: '1.25x' }, submitting: false
{ label: '1.5x' },
{ label: '2.0x' }
]
}, },
_isSeeking: false, _isSeeking: false,
@@ -65,6 +63,9 @@ Page({
app.on('playerStateChange', this._onPlayerChange) app.on('playerStateChange', this._onPlayerChange)
app.on('timeUpdate', this._onTimeUpdate) app.on('timeUpdate', this._onTimeUpdate)
// 查询当前节目点赞状态
this._loadLikeStatus()
}, },
onHide() { onHide() {
@@ -108,7 +109,7 @@ Page({
}, },
/** /**
* 获取频道信息 — 从后端 API 获取 * 获取频道信息
*/ */
_updateDomain() { _updateDomain() {
const content = this.data.activeContent const content = this.data.activeContent
@@ -116,14 +117,12 @@ Page({
var channelId = content.channelId || (content.channel && content.channel.id) var channelId = content.channelId || (content.channel && content.channel.id)
if (!channelId) { if (!channelId) {
// 如果节目数据中直接包含 channel 信息
if (content.channel) { if (content.channel) {
this.setData({ domain: content.channel }) this.setData({ domain: content.channel })
} }
return return
} }
// 如果已经加载过且 channelId 没变,跳过
if (this.data.domain && this.data.domain.id === channelId) return if (this.data.domain && this.data.domain.id === channelId) return
var self = this var self = this
@@ -136,6 +135,20 @@ Page({
}) })
}, },
/**
* 查询当前节目点赞状态
*/
_loadLikeStatus() {
const content = this.data.activeContent
if (!content) return
var self = this
api.getProgramDetail(content.id).then(function (res) {
if (res.code === 200 && res.data) {
self.setData({ isLiked: !!res.data.isLiked })
}
}).catch(function () { })
},
/** /**
* 播放/暂停 * 播放/暂停
*/ */
@@ -251,13 +264,100 @@ Page({
onLike() { onLike() {
const content = this.data.activeContent const content = this.data.activeContent
if (!content) return if (!content) return
api.toggleLike({ contentId: content.id }).then(function (res) { const self = this
wx.showToast({ title: res.code === 200 ? '已收藏 ♥' : '操作失败', icon: 'none' }) const wasLiked = this.data.isLiked
// 乐观更新
this.setData({ isLiked: !wasLiked })
api.toggleLike(content.id).then(function (res) {
if (res.code !== 200) {
// 回滚
self.setData({ isLiked: wasLiked })
wx.showToast({ title: res.msg || '操作失败', icon: 'none' })
} else {
wx.showToast({ title: wasLiked ? '已取消喜欢' : '已喜欢 ♥', icon: 'none' })
}
}).catch(function () { }).catch(function () {
self.setData({ isLiked: wasLiked })
wx.showToast({ title: '网络异常', icon: 'none' }) wx.showToast({ title: '网络异常', icon: 'none' })
}) })
}, },
// ===== 评论弹层 =====
onOpenComments() {
this.setData({ showComments: true })
this._loadComments()
},
onCloseComments() {
this.setData({ showComments: false, commentText: '' })
},
_loadComments() {
const content = this.data.activeContent
if (!content) return
const self = this
this.setData({ commentLoading: true })
api.getCommentList(content.id, { current: 1, pageSize: 30 }).then(function (res) {
if (res.code === 200 && res.data) {
var list = (res.data.list || res.data || []).map(function (c) {
return Object.assign({}, c, {
_isOwn: c.userId === (getApp().globalData.userInfo && getApp().globalData.userInfo.id)
})
})
self.setData({ commentList: list, commentLoading: false })
} else {
self.setData({ commentLoading: false })
}
}).catch(function () {
self.setData({ commentLoading: false })
})
},
onCommentInput(e) {
this.setData({ commentText: e.detail.value })
},
onSubmitComment() {
const text = (this.data.commentText || '').trim()
if (!text) return
const content = this.data.activeContent
if (!content) return
const self = this
this.setData({ submitting: true })
api.addComment(content.id, text).then(function (res) {
self.setData({ submitting: false, commentText: '' })
if (res.code === 200) {
wx.showToast({ title: '发布成功', icon: 'none' })
self._loadComments()
} else {
wx.showToast({ title: res.msg || '发布失败', icon: 'none' })
}
}).catch(function () {
self.setData({ submitting: false })
wx.showToast({ title: '网络异常', icon: 'none' })
})
},
onDeleteComment(e) {
const id = e.currentTarget.dataset.id
const self = this
wx.showModal({
title: '提示',
content: '确认删除该评论吗?',
success(res) {
if (!res.confirm) return
api.deleteComment(id).then(function (r) {
if (r.code === 200) {
self._loadComments()
} else {
wx.showToast({ title: '删除失败', icon: 'none' })
}
})
}
})
},
onShare() { onShare() {
wx.showToast({ title: '分享功能开发中', icon: 'none' }) wx.showToast({ title: '分享功能开发中', icon: 'none' })
}, },
+65 -14
View File
@@ -2,7 +2,7 @@
<page-meta page-style="overflow:hidden; background:#1A1208;" /> <page-meta page-style="overflow:hidden; background:#1A1208;" />
<view class="player-page"> <view class="player-page">
<!-- 状态栏占位(custom 导航模式必须手动留出状态栏高度) --> <!-- 状态栏占位 -->
<view style="height: {{statusBarHeight}}px; flex-shrink: 0;"></view> <view style="height: {{statusBarHeight}}px; flex-shrink: 0;"></view>
<!-- 环境光背景 --> <!-- 环境光背景 -->
@@ -10,7 +10,7 @@
style="background: radial-gradient(ellipse at 50% -10%, {{domain.bgColor || '#FF9D42'}}44 0%, transparent 65%);"> style="background: radial-gradient(ellipse at 50% -10%, {{domain.bgColor || '#FF9D42'}}44 0%, transparent 65%);">
</view> </view>
<!-- 顶部:返回 + 标题 + 分享 --> <!-- 顶部:返回 + 标题 + 评论 -->
<view class="top-bar"> <view class="top-bar">
<view class="top-btn tap-active" bindtap="goBack"> <view class="top-btn tap-active" bindtap="goBack">
<text class="top-back"></text> <text class="top-back"></text>
@@ -19,8 +19,8 @@
<text class="top-label">正在播放</text> <text class="top-label">正在播放</text>
<text class="top-title">{{activeContent.title || '加载中...'}}</text> <text class="top-title">{{activeContent.title || '加载中...'}}</text>
</view> </view>
<view class="top-btn tap-active" bindtap="onShare"> <view class="top-btn tap-active" bindtap="onOpenComments">
<text class="top-share"></text> <text class="top-comment-icon">💬</text>
</view> </view>
</view> </view>
@@ -36,7 +36,7 @@
<view class="ripple-ring" style="animation-delay: 1.4s;"></view> <view class="ripple-ring" style="animation-delay: 1.4s;"></view>
</view> </view>
<!-- 大 emoji,无卡片背景 --> <!-- 大 emoji -->
<text class="cover-emoji {{isPlaying ? 'emoji-active' : ''}}">📻</text> <text class="cover-emoji {{isPlaying ? 'emoji-active' : ''}}">📻</text>
<!-- 频道名 --> <!-- 频道名 -->
@@ -58,11 +58,11 @@
</view> </view>
</view> </view>
<!-- 日期 + like 同行,进度条上方 --> <!-- 日期 + like 同行 -->
<view class="date-like-row"> <view class="date-like-row">
<text class="date-text">{{displayDate}}</text> <text class="date-text">{{displayDate}}</text>
<view class="like-btn-inline tap-active" bindtap="onLike"> <view class="like-btn-inline tap-active" bindtap="onLike">
<text class="like-icon">♡</text> <text class="like-icon {{isLiked ? 'liked' : ''}}">{{isLiked ? '♥' : '♡'}}</text>
</view> </view>
</view> </view>
@@ -107,11 +107,62 @@
</view> </view>
</view> </view>
<!-- 倍速选择面板 --> <!-- 评论弹层 -->
<t-action-sheet <view class="comment-mask" wx:if="{{showComments}}" bindtap="onCloseComments"></view>
visible="{{showSpeedSheet}}" <view class="comment-sheet {{showComments ? 'sheet-up' : ''}}">
items="{{speedItems}}" <view class="sheet-handle"></view>
bind:selected="onSpeedSelect" <view class="sheet-header">
bind:cancel="onSpeedCancel" <text class="sheet-title">评论 ({{commentList.length}})</text>
/> <view class="sheet-close tap-active" bindtap="onCloseComments">
<text class="sheet-close-icon">✕</text>
</view>
</view>
<!-- 输入区 -->
<view class="comment-input-row">
<input
class="comment-input"
placeholder="说点什么..."
placeholder-style="color:rgba(255,200,120,0.3);"
value="{{commentText}}"
bindinput="onCommentInput"
confirm-type="send"
bindconfirm="onSubmitComment"
maxlength="200"
/>
<view class="send-btn tap-active {{submitting ? 'sending' : ''}}" bindtap="onSubmitComment">
<text class="send-text">发送</text>
</view>
</view>
<!-- 评论列表 -->
<scroll-view scroll-y class="comment-list-scroll" enhanced show-scrollbar="{{false}}">
<view wx:if="{{commentLoading}}" class="comment-loading">
<text class="comment-loading-text">加载中...</text>
</view>
<view wx:elif="{{commentList.length === 0}}" class="comment-empty">
<text class="comment-empty-icon">💬</text>
<text class="comment-empty-text">还没有评论,来说第一句话</text>
</view>
<view
wx:for="{{commentList}}"
wx:key="id"
class="comment-item"
>
<view class="comment-avatar">
<text class="comment-avatar-text">{{item.userName ? item.userName[0] : '?'}}</text>
</view>
<view class="comment-body">
<text class="comment-author">{{item.userName || '匿名'}}</text>
<text class="comment-text">{{item.content}}</text>
<text class="comment-time">{{item.createdAtStr || item.createdAt}}</text>
</view>
<view wx:if="{{item._isOwn}}" class="comment-del tap-active" bindtap="onDeleteComment" data-id="{{item.id}}">
<text class="comment-del-icon">🗑</text>
</view>
</view>
<view style="height: 40rpx;"></view>
</scroll-view>
</view>
</view> </view>
+147
View File
@@ -320,3 +320,150 @@
background: #FFF8EE; background: #FFF8EE;
border-radius: 5rpx; border-radius: 5rpx;
} }
/* ── like 状态 ── */
.like-icon.liked {
color: #FF4D6D;
}
.top-comment-icon {
font-size: 38rpx;
line-height: 1;
}
/* ── 评论弹层 ── */
.comment-mask {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 200;
}
.comment-sheet {
position: fixed;
bottom: -100%;
left: 0;
right: 0;
height: 70vh;
background: #231808;
border-radius: 40rpx 40rpx 0 0;
z-index: 201;
display: flex;
flex-direction: column;
padding: 0 0 env(safe-area-inset-bottom);
transition: bottom 0.3s cubic-bezier(0.32,0.72,0,1);
}
.comment-sheet.sheet-up {
bottom: 0;
}
.sheet-handle {
width: 72rpx;
height: 6rpx;
background: rgba(255,200,120,0.2);
border-radius: 3rpx;
margin: 20rpx auto 0;
}
.sheet-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 40rpx;
border-bottom: 1rpx solid rgba(255,200,120,0.08);
}
.sheet-title {
font-size: 30rpx;
font-weight: 700;
color: rgba(255,240,210,0.9);
}
.sheet-close-icon {
font-size: 32rpx;
color: rgba(255,200,120,0.4);
}
.comment-input-row {
display: flex;
align-items: center;
gap: 16rpx;
padding: 20rpx 32rpx;
border-bottom: 1rpx solid rgba(255,200,120,0.08);
}
.comment-input {
flex: 1;
background: rgba(255,200,120,0.07);
border-radius: 40rpx;
padding: 16rpx 28rpx;
font-size: 28rpx;
color: rgba(255,240,210,0.9);
min-height: 0;
}
.send-btn {
background: #FF9D42;
border-radius: 32rpx;
padding: 14rpx 28rpx;
}
.send-btn.sending { opacity: 0.5; }
.send-text {
font-size: 26rpx;
font-weight: 700;
color: #FFF;
}
.comment-list-scroll {
flex: 1;
padding: 8rpx 0;
}
.comment-loading, .comment-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80rpx 0;
gap: 16rpx;
}
.comment-loading-text, .comment-empty-text {
font-size: 26rpx;
color: rgba(255,200,120,0.3);
}
.comment-empty-icon { font-size: 60rpx; }
.comment-item {
display: flex;
align-items: flex-start;
gap: 20rpx;
padding: 24rpx 32rpx;
border-bottom: 1rpx solid rgba(255,200,120,0.05);
}
.comment-avatar {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: rgba(255,157,66,0.25);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.comment-avatar-text {
font-size: 28rpx;
color: #FF9D42;
font-weight: 700;
}
.comment-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.comment-author {
font-size: 24rpx;
font-weight: 600;
color: rgba(255,200,120,0.6);
}
.comment-text {
font-size: 28rpx;
color: rgba(255,240,210,0.85);
line-height: 1.6;
}
.comment-time {
font-size: 22rpx;
color: rgba(255,200,120,0.3);
}
.comment-del {
padding: 8rpx;
}
.comment-del-icon { font-size: 32rpx; }
-37
View File
@@ -9,7 +9,6 @@ Page({
data: { data: {
isVip: false, isVip: false,
userInfo: null, userInfo: null,
subscribedData: [],
menuItems: [ menuItems: [
{ id: 'vip', label: '会员中心', icon: '👑', desc: '未开通', highlight: true }, { id: 'vip', label: '会员中心', icon: '👑', desc: '未开通', highlight: true },
{ id: 'help', label: '帮助与反馈', icon: '❓', desc: '', highlight: false }, { id: 'help', label: '帮助与反馈', icon: '❓', desc: '', highlight: false },
@@ -39,24 +38,6 @@ Page({
userInfo: gd.userInfo userInfo: gd.userInfo
}) })
// 从后端获取已订阅频道
api.getSubscriptionList({ current: 1, pageSize: 50 }).then(function (res) {
if (res.code === 200 && res.data) {
var subList = res.data.list || res.data || []
// 适配封面 URL
subList = subList.map(function (ch) {
return Object.assign({}, ch, {
_coverUrl: (ch.cover && ch.cover.url) || ch.coverUrl || ''
})
})
self.setData({ subscribedData: subList })
}
}).catch(function (err) {
console.error('[Profile] 加载订阅失败:', err)
})
// 更新菜单VIP状态 // 更新菜单VIP状态
var menuItems = this.data.menuItems.slice() var menuItems = this.data.menuItems.slice()
menuItems[0].desc = gd.isVip ? '已开通' : '未开通' menuItems[0].desc = gd.isVip ? '已开通' : '未开通'
@@ -64,24 +45,6 @@ Page({
this.setData({ menuItems: menuItems }) this.setData({ menuItems: menuItems })
}, },
onUnsubscribe(e) {
const id = e.currentTarget.dataset.id
const name = e.currentTarget.dataset.name
const self = this
wx.showModal({
title: '提示',
content: '确定要取消订阅【' + name + '】吗?',
success(res) {
if (res.confirm) {
app.unsubscribeFromDomain(id).then(function () {
self._refresh()
})
}
}
})
},
onMenuTap(e) { onMenuTap(e) {
const id = e.currentTarget.dataset.id const id = e.currentTarget.dataset.id
if (id === 'vip') { if (id === 'vip') {
+46 -58
View File
@@ -1,85 +1,73 @@
<!-- 个人中心 —— 用户信息 + 订阅管理 + 菜单 --> <!-- 个人中心 - 全新极简温暖主题 -->
<view class="profile-page"> <view class="profile-page">
<!-- 橙色头部背景 --> <!-- 沉浸式头部区域 -->
<view class="header-bg"> <view class="header-section">
<view class="user-info"> <!-- 装饰背景 -->
<!-- 头像 --> <view class="header-bg-shape"></view>
<view class="avatar-wrap">
<!-- 用户信息块 -->
<view class="user-info-box">
<view class="avatar-container">
<image wx:if="{{userInfo && userInfo.avatarId}}" src="{{userInfo.avatar.url || ''}}" class="avatar-img" mode="aspectFill" /> <image wx:if="{{userInfo && userInfo.avatarId}}" src="{{userInfo.avatar.url || ''}}" class="avatar-img" mode="aspectFill" />
<text wx:else class="avatar-emoji">😊</text> <text wx:else class="avatar-emoji">😊</text>
</view> </view>
<text class="user-name">
{{userInfo.nickName || userInfo.name || '微信用户'}} <view class="user-details">
<text wx:if="{{isVip}}" class="vip-crown">👑</text> <view class="name-line">
</text> <text class="user-name">{{userInfo.nickName || userInfo.name || '你好,收听者'}}</text>
<text class="user-desc"> <view wx:if="{{isVip}}" class="vip-badge">
{{isVip ? '全频道会员' : '免费用户 · 开通会员畅听全频道'}} <text class="vip-badge-icon">👑</text>
</text> <text class="vip-badge-text">VIP会员</text>
</view>
</view>
<!-- 订阅管理卡片(上移覆盖) -->
<view class="sub-card-wrap">
<view class="card sub-card">
<!-- 空状态 -->
<view wx:if="{{subscribedData.length === 0}}" class="sub-empty">
<text class="sub-empty-text">暂未订阅任何频道</text>
<button class="btn-ghost" bindtap="goDiscover">去广场添加</button>
</view>
<!-- 订阅列表 -->
<view wx:else>
<view
wx:for="{{subscribedData}}"
wx:key="id"
class="sub-item"
>
<view class="sub-item-left">
<view class="sub-icon" style="background: {{item.bgColor || '#F0F0F0'}};">
<image wx:if="{{item._coverUrl}}" src="{{item._coverUrl}}" class="sub-cover-img" mode="aspectFill" />
<text wx:else class="sub-emoji">{{item.icon || '📻'}}</text>
</view>
<view class="sub-info">
<text class="sub-name">{{item.name}}</text>
<text class="sub-tag {{item.isFree === 1 ? 'free' : ''}}">
{{item.isFree === 1 ? '免费' : '已订阅'}}
</text>
</view>
</view>
<view class="sub-del tap-active" bindtap="onUnsubscribe" data-id="{{item.id}}" data-name="{{item.name}}">
<text class="del-icon">🗑</text>
</view> </view>
</view> </view>
<text class="user-id">今日也要元气满满哦</text>
</view> </view>
</view> </view>
</view> </view>
<!-- 菜单列表 --> <!-- 内容区 -->
<view class="menu-card-wrap"> <view class="content-section">
<view class="card menu-card">
<!-- VIP 专属引导卡片 (若未开通) -->
<view wx:if="{{!isVip}}" class="vip-promo-card tap-active" bindtap="onMenuTap" data-id="vip">
<view class="promo-left">
<text class="promo-icon">✨</text>
<view class="promo-text">
<text class="promo-title">开通全频道会员</text>
<text class="promo-desc">畅听无阻 · 专属标识 · 尊贵体验</text>
</view>
</view>
<view class="promo-right">
<text class="promo-btn">立即开通</text>
</view>
</view>
<!-- 菜单列表卡片 -->
<view class="menu-list-card">
<view <view
wx:for="{{menuItems}}" wx:for="{{menuItems}}"
wx:key="id" wx:key="id"
class="menu-item tap-active" class="menu-row tap-active"
bindtap="onMenuTap" bindtap="onMenuTap"
data-id="{{item.id}}" data-id="{{item.id}}"
> >
<view class="menu-item-left"> <view class="menu-left">
<view class="menu-icon-wrap {{item.highlight ? 'highlight' : ''}}"> <view class="menu-icon-box {{item.id === 'vip' ? 'is-vip-icon' : ''}}">
<text class="menu-emoji">{{item.icon}}</text> <text class="menu-emoji">{{item.icon}}</text>
</view> </view>
<text class="menu-label">{{item.label}}</text> <text class="menu-title">{{item.label}}</text>
</view> </view>
<view class="menu-item-right"> <view class="menu-right">
<text class="menu-desc {{item.highlight ? 'highlight' : ''}}">{{item.desc}}</text> <text class="menu-status {{item.highlight ? 'highlight-text' : ''}}">{{item.desc}}</text>
<text class="menu-arrow"></text> <text class="menu-arrow"></text>
</view> </view>
</view> </view>
</view> </view>
</view> </view>
<view style="height: 200rpx;"></view> <!-- 底部留白以防遮挡播放器 -->
<view class="bottom-spacer"></view>
<global-player /> <global-player />
</view> </view>
+198 -159
View File
@@ -1,223 +1,262 @@
/* 个人中心样式 */ /* 个人中心 - 全新极简温暖主题 */
:root {
--primary-color: #F38600;
--bg-color: #F7F8FA;
--card-bg: #FFFFFF;
--text-main: #2C2C2C;
--text-sub: #8E8E93;
}
.profile-page { .profile-page {
min-height: 100vh; min-height: 100vh;
background: var(--color-bg-page); background-color: #F9F9F9;
position: relative;
overflow-x: hidden;
} }
/* 橙色头部 */ /* 沉浸式头部 */
.header-bg { .header-section {
background: #F38600; position: relative;
border-radius: 0 0 48rpx 48rpx; width: 100%;
padding-bottom: 200rpx; padding-top: 140rpx;
padding-bottom: 80rpx;
z-index: 1;
} }
.user-info {
.header-bg-shape {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #FFF0E0 0%, #FFDFB8 100%);
border-bottom-left-radius: 64rpx;
border-bottom-right-radius: 64rpx;
z-index: -1;
box-shadow: 0 4rpx 24rpx rgba(243, 134, 0, 0.08);
}
.user-info-box {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
padding: 32rpx 40rpx; padding: 0 48rpx;
} }
.avatar-wrap {
width: 128rpx; .avatar-container {
height: 128rpx; width: 140rpx;
height: 140rpx;
border-radius: 50%; border-radius: 50%;
border: 6rpx solid rgba(255, 255, 255, 0.3); border: 6rpx solid rgba(255, 255, 255, 0.6);
background: linear-gradient(135deg, #FFB366, #FFE0B2); background: #FFF;
box-shadow: 0 12rpx 32rpx rgba(243, 134, 0, 0.15);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-bottom: 20rpx;
overflow: hidden; overflow: hidden;
flex-shrink: 0;
} }
.avatar-emoji {
font-size: 64rpx;
}
.avatar-img { .avatar-img {
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: 50%; object-fit: cover;
}
.user-name {
font-size: 32rpx;
font-weight: 700;
color: #FFF;
letter-spacing: 2rpx;
}
.vip-crown {
font-size: 28rpx;
margin-left: 8rpx;
}
.user-desc {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.9);
margin-top: 8rpx;
font-weight: 500;
text-align: center;
} }
/* 订阅卡片 */ .avatar-emoji {
.sub-card-wrap { font-size: 72rpx;
}
.user-details {
margin-left: 36rpx;
display: flex;
flex-direction: column;
justify-content: center;
}
.name-line {
display: flex;
align-items: center;
margin-bottom: 12rpx;
}
.user-name {
font-size: 40rpx;
font-weight: 800;
color: #333333;
letter-spacing: 2rpx;
}
.vip-badge {
display: flex;
align-items: center;
background: linear-gradient(90deg, #333333 0%, #1A1A1A 100%);
padding: 4rpx 16rpx;
border-radius: 999rpx;
margin-left: 16rpx;
}
.vip-badge-icon {
font-size: 20rpx;
margin-right: 6rpx;
}
.vip-badge-text {
font-size: 20rpx;
font-weight: 700;
color: #FDF1C2;
}
.user-id {
font-size: 24rpx;
color: #8C6F50;
font-weight: 500;
}
/* 内容区 */
.content-section {
padding: 0 32rpx; padding: 0 32rpx;
margin-top: -140rpx; margin-top: -30rpx;
position: relative; position: relative;
z-index: 10; z-index: 10;
} }
.sub-card {
padding: 32rpx;
}
/* 空状态 */ /* VIP宣发引导卡片 */
.sub-empty { .vip-promo-card {
text-align: center;
padding: 40rpx 0;
}
.sub-empty-text {
display: block;
font-size: 26rpx;
color: #999;
margin-bottom: 24rpx;
}
/* 订阅列表项 */
.sub-item {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 20rpx; background: linear-gradient(135deg, #FFEFD5 0%, #FFE4B5 100%);
padding: 32rpx 40rpx;
border-radius: 32rpx; border-radius: 32rpx;
background: #FEFEFE; box-shadow: 0 8rpx 24rpx rgba(217, 119, 6, 0.08);
border: 1rpx solid rgba(0,0,0,0.04); margin-bottom: 32rpx;
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.02); border: 1rpx solid rgba(255, 255, 255, 0.5);
margin-bottom: 16rpx;
}
.sub-item-left {
display: flex;
align-items: center;
flex: 1;
overflow: hidden;
}
.sub-icon {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.1);
}
.sub-emoji {
font-size: 36rpx;
}
.sub-cover-img {
width: 100%;
height: 100%;
border-radius: 50%;
}
.sub-info {
margin-left: 24rpx;
overflow: hidden;
}
.sub-name {
display: block;
font-size: 28rpx;
font-weight: 700;
color: #333;
}
.sub-tag {
display: inline-block;
margin-top: 8rpx;
padding: 4rpx 12rpx;
border-radius: 6rpx;
font-size: 20rpx;
font-weight: 700;
}
.sub-tag.free {
background: rgba(45, 90, 39, 0.1);
color: #2D5A27;
} }
.sub-del { .promo-left {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: #F5F5F5;
border: 1rpx solid #EEE;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
flex-shrink: 0;
}
.del-icon {
font-size: 24rpx;
} }
.sub-notice { .promo-icon {
display: block; font-size: 48rpx;
font-size: 20rpx; margin-right: 24rpx;
color: rgba(255, 157, 66, 0.7); }
text-align: center;
margin-top: 12rpx; .promo-text {
display: flex;
flex-direction: column;
}
.promo-title {
font-size: 30rpx;
font-weight: 800;
color: #8B4513;
margin-bottom: 6rpx;
}
.promo-desc {
font-size: 22rpx;
color: #AA7A55;
font-weight: 500; font-weight: 500;
} }
/* 菜单卡片 */ .promo-btn {
.menu-card-wrap { background: #333333;
padding: 24rpx 32rpx 0; color: #FDF1C2;
font-size: 24rpx;
font-weight: 700;
padding: 12rpx 28rpx;
border-radius: 999rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
} }
.menu-card {
padding: 8rpx 0; /* 列表菜单 */
.menu-list-card {
background: #FFFFFF;
border-radius: 32rpx;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.02);
padding: 16rpx 0;
} }
.menu-item {
.menu-row {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 28rpx 32rpx; padding: 32rpx 40rpx;
border-bottom: 1rpx solid rgba(0,0,0,0.03); position: relative;
} }
.menu-item:last-child {
border-bottom: none; .menu-row::after {
content: '';
position: absolute;
bottom: 0;
left: 110rpx;
right: 40rpx;
border-bottom: 1rpx solid #F5F5F5;
} }
.menu-item-left { .menu-row:last-child::after {
display: none;
}
.menu-left {
display: flex; display: flex;
align-items: center; align-items: center;
} }
.menu-icon-wrap {
width: 52rpx; .menu-icon-box {
height: 52rpx; width: 60rpx;
border-radius: 50%; height: 60rpx;
background: #F5F5F5; background: #F8F9FA;
border-radius: 20rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-right: 20rpx; margin-right: 28rpx;
} }
.menu-icon-wrap.highlight {
background: rgba(251, 191, 36, 0.1); .menu-icon-box.is-vip-icon {
background: rgba(251, 191, 36, 0.15);
} }
.menu-emoji { .menu-emoji {
font-size: 24rpx; font-size: 32rpx;
} }
.menu-label {
font-size: 28rpx; .menu-title {
font-weight: 700; font-size: 30rpx;
color: #333; font-weight: 600;
letter-spacing: 2rpx; color: #333333;
} }
.menu-item-right {
.menu-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12rpx;
} }
.menu-desc {
font-size: 22rpx; .menu-status {
color: #999; font-size: 24rpx;
font-weight: 500; color: #999999;
margin-right: 12rpx;
} }
.menu-desc.highlight {
.menu-status.highlight-text {
color: #D97706; color: #D97706;
font-weight: 600;
} }
.menu-arrow { .menu-arrow {
font-size: 28rpx; font-size: 32rpx;
color: #CCC; color: #CCCCCC;
}
.bottom-spacer {
height: 240rpx;
}
/* 点击态 */
.tap-active:active {
opacity: 0.7;
transform: scale(0.98);
transition: all 0.2s;
} }
+111 -16
View File
@@ -37,7 +37,6 @@ Page({
const quarterly = (parseFloat(options.quarterlyPrice) || 0) / 100 const quarterly = (parseFloat(options.quarterlyPrice) || 0) / 100
const annual = (parseFloat(options.annualPrice) || 0) / 100 const annual = (parseFloat(options.annualPrice) || 0) / 100
// 默认选中包年,否则最合算的
let defaultPlan = 'monthly' let defaultPlan = 'monthly'
let defaultPrice = monthly let defaultPrice = monthly
if (annual > 0) { defaultPlan = 'annual'; defaultPrice = annual } if (annual > 0) { defaultPlan = 'annual'; defaultPrice = annual }
@@ -59,15 +58,42 @@ Page({
}) })
} else { } else {
// ─── VIP 会员模式 ─── // ─── VIP 会员模式 ───
const gd = app.globalData
this.setData({ this.setData({
mode: 'vip', mode: 'vip',
isVip: app.globalData.isVip, isVip: gd.isVip,
selectedPlan: 'vip-all', selectedPlan: 'vip-all',
currentPrice: '19.9' currentPrice: '--',
vipExpireAt: gd.vipExpireAt ? gd.vipExpireAt.substring(0, 10) : ''
}) })
// 从后端拉 VIP 配置
this._loadVipConfig()
} }
}, },
_loadVipConfig() {
const self = this
api.getVipConfig().then(function (res) {
if (res.code === 200 && res.data) {
var cfg = res.data
// 后端单位:分 → 元
var price = cfg.discountedPrice > 0 ? (cfg.discountedPrice / 100).toFixed(2) : (cfg.price / 100).toFixed(2)
var originalPrice = cfg.price > 0 ? (cfg.price / 100).toFixed(2) : ''
var hasDiscount = cfg.discountedPrice > 0 && cfg.discountedPrice < cfg.price
self.setData({
currentPrice: price,
vipPrice: price,
vipOriginalPrice: hasDiscount ? originalPrice : '',
vipRemark: cfg.remark || ''
})
}
}).catch(function (err) {
console.error('[VIP] 获取配置失败:', err)
// 容错:使用默认价格
self.setData({ currentPrice: '19.90', vipPrice: '19.90', vipOriginalPrice: '29.90' })
})
},
onShow() { onShow() {
if (this.data.mode === 'vip') { if (this.data.mode === 'vip') {
this.setData({ isVip: app.globalData.isVip }) this.setData({ isVip: app.globalData.isVip })
@@ -95,20 +121,42 @@ Page({
const { mode, selectedPlan, currentPrice, channelId } = this.data const { mode, selectedPlan, currentPrice, channelId } = this.data
if (mode === 'vip') { if (mode === 'vip') {
// ── VIP 全频道(模拟,后续接入时替换) ── // ── VIP 永久会员:调后端预支付接口 ──
wx.showModal({ wx.showLoading({ title: '获取支付信息...' })
title: '确认支付',
content: `即将支付 ¥${currentPrice} 开通全频道会员`, api.initiateVipPayment()
success(res) { .then(function (res) {
if (res.confirm) { if (res.code !== 200 || !res.data || !res.data.payments) {
app.upgradeVip() wx.hideLoading()
wx.showToast({ title: '开通成功!', icon: 'success' }) wx.showToast({ title: res.msg || '获取支付信息失败', icon: 'none' })
setTimeout(function () { return
self.setData({ isVip: true })
}, 500)
} }
}
}) const payments = res.data.payments
const outTradeNo = res.data.outTradeNo
wx.hideLoading()
wx.requestPayment({
timeStamp: payments.timeStamp,
nonceStr: payments.nonceStr,
package: payments.package,
signType: payments.signType || 'RSA',
paySign: payments.paySign,
success() {
self._pollVipStatus(outTradeNo, 3, 2000)
},
fail(err) {
if (err.errMsg && err.errMsg.indexOf('cancel') > -1) return
wx.showToast({ title: '支付失败,请重试', icon: 'none' })
console.error('[VIP支付] wx.requestPayment 失败:', err)
}
})
})
.catch(function (err) {
wx.hideLoading()
console.error('[VIP支付] 接口请求失败:', err)
wx.showToast({ title: '网络异常,请重试', icon: 'none' })
})
return return
} }
@@ -216,6 +264,53 @@ Page({
}) })
}, },
/**
* 轮询 VIP 支付状态
* 成功后更新全局 isVip + 触发 vipChange 事件
*/
_pollVipStatus(outTradeNo, retries, interval) {
const self = this
wx.showLoading({ title: '验证中...' })
api.queryPayStatus(outTradeNo)
.then(function (paid) {
if (paid) {
// ✅ VIP 开通确认成功
wx.hideLoading()
// 更新全局状态
app.globalData.isVip = true
app.emit('vipChange', { isVip: true })
self.setData({ isVip: true })
wx.showToast({ title: '🎉 VIP 开通成功!', icon: 'none' })
setTimeout(function () { wx.navigateBack() }, 1500)
} else if (retries > 1) {
// 🔄 尚未到账,等待后重试
setTimeout(function () {
self._pollVipStatus(outTradeNo, retries - 1, interval)
}, interval)
} else {
// ⏳ 重试耗尽,乐观提示
wx.hideLoading()
wx.showModal({
title: '支付处理中',
content: 'VIP 开通已提交,正在确认中,稍后请重新进入查看',
showCancel: false,
success: function () { wx.navigateBack() }
})
}
})
.catch(function (err) {
wx.hideLoading()
console.error('[VIP支付] 查询状态失败:', err)
wx.showModal({
title: '支付处理中',
content: 'VIP 开通已提交,稍后请刷新查看是否生效',
showCancel: false,
success: function () { wx.navigateBack() }
})
})
},
goBack() { goBack() {
wx.navigateBack() wx.navigateBack()
} }
+55 -25
View File
@@ -2,12 +2,56 @@
<page-meta page-style="overflow: hidden;" /> <page-meta page-style="overflow: hidden;" />
<view class="vip-page"> <view class="vip-page">
<!-- ── 已是VIP直接返回 ── --> <!-- ── 已是 VIP展示会员权益页 ── -->
<view wx:if="{{isVip && mode === 'vip'}}" class="vip-done"> <view wx:if="{{isVip && mode === 'vip'}}" class="vip-done">
<text class="vip-done-icon">👑</text>
<text class="vip-done-title">您已经是全频道会员</text> <!-- 头部云光装饰 -->
<text class="vip-done-desc">畅享全部频道,尊享专属权益</text> <view class="vip-glow"></view>
<button class="done-back-btn" bindtap="goBack">返回</button>
<!-- 安全区域占位 -->
<view style="height: {{statusBarHeight}}px;"></view>
<!-- 嵇章区 -->
<view class="vip-done-hero">
<view class="vip-crown-wrap">
<text class="vip-crown">👑</text>
<view class="vip-crown-ring ring-1"></view>
<view class="vip-crown-ring ring-2"></view>
</view>
<text class="vip-done-title">全频道会员</text>
<view wx:if="{{vipExpireAt}}" class="vip-expire-badge">
<text class="vip-expire-text">永久有效 · 不限期限</text>
</view>
<view wx:else class="vip-expire-badge">
<text class="vip-expire-text">永久有效 · 不限期限</text>
</view>
</view>
<!-- 权益卡片网格 -->
<view class="vip-done-benefits">
<view class="vip-benefit-item">
<text class="vb-icon">🔓</text>
<text class="vb-name">全频道解锁</text>
</view>
<view class="vip-benefit-item">
<text class="vb-icon">🎧</text>
<text class="vb-name">免广告收听</text>
</view>
<view class="vip-benefit-item">
<text class="vb-icon">⭐</text>
<text class="vb-name">优先推送</text>
</view>
<view class="vip-benefit-item">
<text class="vb-icon">💬</text>
<text class="vb-name">互动评论</text>
</view>
</view>
<!-- 返回按钮 -->
<view class="vip-done-actions">
<button class="done-back-btn" bindtap="goBack">返回收听</button>
</view>
</view> </view>
<!-- ── 主Scroll区域 ── --> <!-- ── 主Scroll区域 ── -->
@@ -23,7 +67,7 @@
<view class="vip-hero"> <view class="vip-hero">
<text class="vip-hero-title">{{mode === 'channel' ? channelName : '开通全频道会员'}}</text> <text class="vip-hero-title">{{mode === 'channel' ? channelName : '开通全频道会员'}}</text>
<text class="vip-hero-desc"> <text class="vip-hero-desc">
{{mode === 'channel' ? '选择适合你的订阅方案,随时随地收听' : '解锁全部频道,告别无聊早晨'}} {{mode === 'channel' ? '选择适合你的订阅方案,随时随地收听' : '一次开通,永久解锁全部频道'}}
</text> </text>
</view> </view>
@@ -51,20 +95,6 @@
<text class="benefit-desc">收听无任何打扰</text> <text class="benefit-desc">收听无任何打扰</text>
</view> </view>
</view> </view>
<view class="benefit-item">
<text class="benefit-check">⬇️</text>
<view class="benefit-info">
<text class="benefit-name">音频全量下载</text>
<text class="benefit-desc">支持离线随时听</text>
</view>
</view>
<view class="benefit-item">
<text class="benefit-check">⏰</text>
<view class="benefit-info">
<text class="benefit-name">晨间定时播</text>
<text class="benefit-desc">专属智能闹钟</text>
</view>
</view>
</view> </view>
</view> </view>
</view> </view>
@@ -77,14 +107,14 @@
bindtap="selectPlan" bindtap="selectPlan"
data-plan="vip-all" data-plan="vip-all"
> >
<view wx:if="{{selectedPlan === 'vip-all'}}" class="plan-badge">限时特惠</view> <view wx:if="{{vipOriginalPrice}}" class="plan-badge">限时特惠</view>
<view class="plan-info"> <view class="plan-info">
<text class="plan-name">全频道连续包月</text> <text class="plan-name">永久会员</text>
<text class="plan-desc">自动续费,随时可取消</text> <text class="plan-desc">{{vipRemark || '一次购买,永久畅听全部频道'}}</text>
</view> </view>
<view class="plan-price"> <view class="plan-price">
<text class="price-amount"><text class="price-symbol">¥</text>19.9</text> <text class="price-amount"><text class="price-symbol">¥</text>{{vipPrice || currentPrice}}</text>
<text class="price-original">¥29.9</text> <text class="price-original" wx:if="{{vipOriginalPrice}}">¥{{vipOriginalPrice}}</text>
</view> </view>
</view> </view>
</view> </view>
+134 -21
View File
@@ -9,39 +9,152 @@
overflow: hidden; overflow: hidden;
} }
/* 已是VIP */ /* 已是VIP 页面整体 */
.vip-done { .vip-done {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center;
height: 100vh; height: 100vh;
padding: 40rpx; background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
background: #FFFFFF; position: relative;
overflow: hidden;
} }
.vip-done-icon {
font-size: 120rpx; /* 头部云光装饰 */
margin-bottom: 40rpx; .vip-glow {
position: absolute;
top: -150rpx;
left: 50%;
transform: translateX(-50%);
width: 1000rpx;
height: 1000rpx;
background: radial-gradient(circle, rgba(251, 191, 36, 0.15) 0%, rgba(0,0,0,0) 60%);
z-index: 1;
} }
/* 勋章区 */
.vip-done-hero {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 120rpx;
z-index: 2;
}
.vip-crown-wrap {
width: 180rpx;
height: 180rpx;
background: linear-gradient(135deg, #FFDF8A 0%, #D99B22 100%);
border-radius: 50%;
border: 4rpx solid rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
position: relative;
box-shadow: 0 16rpx 40rpx rgba(217, 155, 34, 0.4);
margin-bottom: 60rpx;
}
.vip-crown {
font-size: 80rpx;
z-index: 10;
}
.vip-crown-ring {
position: absolute;
border-radius: 50%;
border: 2rpx solid rgba(255, 223, 138, 0.4);
}
.ring-1 {
width: 240rpx;
height: 240rpx;
animation: pulse 2s infinite;
}
.ring-2 {
width: 300rpx;
height: 300rpx;
animation: pulse 2s infinite 1s;
}
@keyframes pulse {
0% { transform: scale(0.8); opacity: 1; }
100% { transform: scale(1.2); opacity: 0; }
}
.vip-done-title { .vip-done-title {
font-size: 36rpx; font-size: 44rpx;
font-weight: 700; font-weight: 800;
color: #333; color: #FFF;
margin-bottom: 16rpx; letter-spacing: 4rpx;
margin-bottom: 24rpx;
text-shadow: 0 4rpx 16rpx rgba(0,0,0,0.5);
} }
.vip-done-desc {
font-size: 28rpx; .vip-expire-badge {
color: #999; background: rgba(255, 255, 255, 0.1);
margin-bottom: 48rpx; padding: 12rpx 32rpx;
}
.done-back-btn {
padding: 16rpx 48rpx;
background: #F5F5F5;
border-radius: 999rpx; border-radius: 999rpx;
font-size: 28rpx; border: 1rpx solid rgba(251, 191, 36, 0.3);
}
.vip-expire-text {
font-size: 24rpx;
color: #FCD34D;
font-weight: 500; font-weight: 500;
}
/* 权益网格 */
.vip-done-benefits {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 24rpx 0;
margin-top: 80rpx;
padding: 0 50rpx;
z-index: 2;
width: 100%;
box-sizing: border-box;
}
.vip-benefit-item {
width: 48%;
background: rgba(255, 255, 255, 0.06);
border: 1rpx solid rgba(255, 255, 255, 0.1);
border-radius: 24rpx;
padding: 32rpx 24rpx;
display: flex;
align-items: center;
box-sizing: border-box;
}
.vb-icon {
font-size: 40rpx;
margin-right: 16rpx;
}
.vb-name {
font-size: 28rpx;
color: #FFF;
font-weight: 600;
}
/* 按钮操作区 */
.vip-done-actions {
margin-top: auto;
margin-bottom: 120rpx;
z-index: 2;
}
.done-back-btn {
background: linear-gradient(90deg, #FCD34D 0%, #F59E0B 100%);
color: #5F370E;
font-size: 32rpx;
font-weight: 700;
padding: 24rpx 120rpx;
border-radius: 999rpx;
box-shadow: 0 8rpx 32rpx rgba(245, 158, 11, 0.4);
border: none; border: none;
color: #666; line-height: 1.5;
} }
.done-back-btn::after { border: none; } .done-back-btn::after { border: none; }
+58 -6
View File
@@ -110,7 +110,7 @@ function unsubscribe(channelId) {
/** 添加收听历史 */ /** 添加收听历史 */
function addHistory(params) { function addHistory(params) {
return post('/radio/history/add', { return post('/history/add', {
programId: params.programId, programId: params.programId,
progress: params.progress || 0, progress: params.progress || 0,
duration: params.duration || 0 duration: params.duration || 0
@@ -119,27 +119,42 @@ function addHistory(params) {
/** 获取收听历史列表 */ /** 获取收听历史列表 */
function getHistoryList(params) { function getHistoryList(params) {
return post('/radio/history/list', { return post('/history/list', {
current: (params && params.current) || 1, current: (params && params.current) || 1,
pageSize: (params && params.pageSize) || 20 pageSize: (params && params.pageSize) || 20
}) })
} }
/** 删除单条收听历史 */
function deleteHistory(programId) {
return post('/history/delete', { programId })
}
/** 清空全部收听历史 */
function deleteAllHistory() {
return get('/history/deleteAll')
}
// ======================== 收藏 ======================== // ======================== 收藏 ========================
/** 添加收藏 */ /** 添加收藏 */
function addFavorite(programId) { function addFavorite(programId) {
return post('/radio/favorite/add', { programId }) return post('/favorite/add', { programId })
} }
/** 取消收藏 */ /** 取消收藏 */
function removeFavorite(programId) { function removeFavorite(programId) {
return post('/radio/favorite/remove', { programId }) return post('/favorite/remove', { programId })
}
/** 清空全部收藏 */
function removeAllFavorites() {
return get('/favorite/removeAll')
} }
/** 获取收藏列表 */ /** 获取收藏列表 */
function getFavoriteList(params) { function getFavoriteList(params) {
return post('/radio/favorite/list', { return post('/favorite/list', {
current: (params && params.current) || 1, current: (params && params.current) || 1,
pageSize: (params && params.pageSize) || 20 pageSize: (params && params.pageSize) || 20
}) })
@@ -149,10 +164,39 @@ function getFavoriteList(params) {
/** 切换点赞 */ /** 切换点赞 */
function toggleLike(programId) { function toggleLike(programId) {
return post('/radio/like/toggle', { programId }) return post('/like/toggle', { programId })
}
/** 添加评论 */
function addComment(programId, content) {
return post('/comment/add', { programId, content })
}
/** 删除评论 */
function deleteComment(id) {
return post('/comment/delete', { id })
}
/** 获取评论列表 */
function getCommentList(programId, params) {
return post('/comment/list', {
programId,
current: (params && params.current) || 1,
pageSize: (params && params.pageSize) || 20
})
} }
/** 获取当前登录用户 */ /** 获取当前登录用户 */
/** 获取 VIP 配置(价格等) */
function getVipConfig() {
return post('/vip/config/detail', {})
}
/** 发起 VIP 开通预支付 */
function initiateVipPayment() {
return post('/vip/vip',{})
}
function getUserInfo() { function getUserInfo() {
return get('/user/info') return get('/user/info')
} }
@@ -205,10 +249,18 @@ module.exports = {
unsubscribe, unsubscribe,
addHistory, addHistory,
getHistoryList, getHistoryList,
deleteHistory,
deleteAllHistory,
addFavorite, addFavorite,
removeFavorite, removeFavorite,
removeAllFavorites,
getFavoriteList, getFavoriteList,
toggleLike, toggleLike,
addComment,
deleteComment,
getCommentList,
getVipConfig,
initiateVipPayment,
getUserInfo, getUserInfo,
subscribeChannel, subscribeChannel,
unlockChannel, unlockChannel,
+8 -11
View File
@@ -10,6 +10,7 @@ const api = require('./api')
let bgAudioManager = null let bgAudioManager = null
let appInstance = null let appInstance = null
let _switching = false // 切换音频时的锁,防止 onStop 事件干扰 let _switching = false // 切换音频时的锁,防止 onStop 事件干扰
let _ended = false // 标记音频是否已自然播放完毕
/** /**
* 初始化音频管理器 * 初始化音频管理器
@@ -42,6 +43,7 @@ function init(app) {
}) })
bgAudioManager.onEnded(() => { bgAudioManager.onEnded(() => {
_ended = true
reportHistory() reportHistory()
updatePlayState(false) updatePlayState(false)
appInstance.globalData.currentTime = appInstance.globalData.duration appInstance.globalData.currentTime = appInstance.globalData.duration
@@ -140,6 +142,7 @@ function playContent(content) {
// 标记正在切换,防止旧音频的 onStop 干扰 // 标记正在切换,防止旧音频的 onStop 干扰
_switching = true _switching = true
_ended = false // 重置结束标记
appInstance.globalData.activeContent = content appInstance.globalData.activeContent = content
appInstance.globalData.currentTime = 0 appInstance.globalData.currentTime = 0
@@ -167,18 +170,12 @@ function togglePlay() {
if (appInstance.globalData.isPlaying) { if (appInstance.globalData.isPlaying) {
bgAudioManager.pause() bgAudioManager.pause()
} else { } else {
// 检查 bgAudioManager 是否还有有效的 src // 音频已自然播放完毕,或 src 被系统回收 → 从头重新播放
// 音频结束/被系统回收后,src 会变为空,此时 play() 无效 var srcEmpty = false
// 需要重新设置 src 来恢复播放 try { srcEmpty = !bgAudioManager.src } catch (e) { srcEmpty = true }
var currentSrc = ''
try {
currentSrc = bgAudioManager.src
} catch (e) {
currentSrc = ''
}
if (!currentSrc) { if (_ended || srcEmpty) {
// src 已被清空(音频结束、系统回收等),重新播放 _ended = false
playContent(appInstance.globalData.activeContent) playContent(appInstance.globalData.activeContent)
} else { } else {
bgAudioManager.play() bgAudioManager.play()
+2 -2
View File
@@ -4,8 +4,8 @@
*/ */
// 接口基础地址(预留占位符,对接后端时替换) // 接口基础地址(预留占位符,对接后端时替换)
const API_BASE_URL = 'https://radio.sundynix.cn/api' //const API_BASE_URL = 'https://radio.sundynix.cn/api'
//const API_BASE_URL = 'http://192.168.0.184:8888' const API_BASE_URL = 'http://192.168.0.184:8888'
/** /**