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
+53 -21
View File
@@ -13,10 +13,12 @@ Page({
data: {
domain: {},
isSubscribed: false,
isExpired: false, // 订阅是否已过期
expiredAt: '', // 到期时间(格式化)
isFree: false, // 快捷字段,避免模板 domain.isFree
isExpired: false,
expiredAt: '',
isFree: false,
isVipOnly: false,
isVip: false,
canPlay: false,
domainContents: [],
isPlaying: false,
loading: true
@@ -52,7 +54,6 @@ Page({
const ch = res.data
const isFree = ch.isFree === 1
// 免费频道:不关心订阅状态和到期时间
var expiredAt = ''
var isExpired = false
var isSubscribed = false
@@ -64,13 +65,18 @@ Page({
isSubscribed = ch.hasSubscribed === 1 && !isExpired
}
// 可播放:VIP 或免费频道或已订阅
const canPlay = app.globalData.isVip || isFree || isSubscribed
self.setData({
domain: ch,
isSubscribed,
isExpired,
expiredAt,
isFree,
isVipOnly: ch.isVipOnly === 1
isVipOnly: ch.isVipOnly === 1,
isVip: app.globalData.isVip,
canPlay
})
}
}).catch(function (err) {
@@ -92,8 +98,7 @@ Page({
}
var gd = app.globalData
var isSubscribed = self.data.isSubscribed
var isFree = self.data.domain.isFree === 1
var canPlay = self.data.canPlay
var total = contents.length
contents = contents.map(function (item, idx) {
@@ -102,7 +107,7 @@ Page({
_dateDot: item.createdAt ? item.createdAt.substring(0, 10).replace(/-/g, '.') : '',
durationText: util.formatTime(item.duration || 0),
_isThisPlaying: gd.activeContent && gd.activeContent.id === item.id,
_isLocked: !isSubscribed && !isFree && idx > 0
_isLocked: !canPlay // 所有集全部锁定,没有试听
})
})
@@ -133,11 +138,12 @@ Page({
onPlayItem(e) {
const id = e.currentTarget.dataset.id
const idx = parseInt(e.currentTarget.dataset.idx)
const gd = app.globalData
if (!this.data.isSubscribed && !(this.data.domain.isFree === 1) && idx > 0) {
wx.showToast({ title: '请先订阅该频道以解锁往期内容', icon: 'none' })
// 核心权限判断:VIP || 免费频道 || 已订阅
if (!this.data.canPlay) {
// 引导到支付页
this.onSubscribe()
return
}
@@ -161,25 +167,51 @@ Page({
const id = this._domainId
const domain = this.data.domain
// 已订阅 →订阅中,无需操作
if (this.data.isSubscribed) {
wx.showToast({ title: '您已订阅该频道', icon: 'none' })
// 已可播放(VIP / 免费 / 已订阅)——正常情况下不会触发此方法
if (this.data.canPlay) {
wx.showToast({ title: '您已可收听该频道', icon: 'none' })
return
}
// 免费频道 → 直接收听
// VIP专享频道:不支持单独订阅,必须开通 VIP
if (domain.isVipOnly === 1) {
wx.navigateTo({ url: '/pages/vip/index' })
return
}
// 免费频道(理论上不会到这里,安全先)
if (domain.isFree === 1) {
wx.showToast({ title: '免费频道,直接收听!', icon: 'none' })
return
}
// VIP专享且未开通 → VIP
if (domain.isVipOnly === 1 && !app.globalData.isVip) {
wx.navigateTo({ url: '/pages/vip/index' })
return
}
// 付费频道(未订阅 / 已过期)——跳转订阅/支付
var params = 'channelId=' + id
+ '&channelName=' + encodeURIComponent(domain.name || '')
+ '&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
+ '&channelName=' + encodeURIComponent(domain.name || '')
+ '&monthlyPrice=' + (domain.monthlyPrice || 0)
+38 -33
View File
@@ -9,10 +9,27 @@
<text class="hero-name">{{domain.name}}</text>
<text class="hero-tag">{{domain.tag || domain.description || ''}}</text>
<!-- ═══ 按钮区:根据频道类型和订阅状态分情况 ═══ -->
<!-- ═══ 按钮区 ═══ -->
<!-- 1. 免费频道 -->
<block wx:if="{{isFree}}">
<!-- 0. 可播放(VIP / 免费 / 已订阅)→ 直接收听 -->
<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>
<button class="hero-sub-btn free-btn" bindtap="onSubscribe">
<text>▶ 开始收听</text>
@@ -27,24 +44,16 @@
</button>
</block>
<!-- 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. 订阅已过期(重新订阅) -->
<!-- 3. 订阅已过期 -->
<block wx:elif="{{isExpired}}">
<view class="hero-badge expired-badge">⏰ 订阅已到期</view>
<button class="hero-sub-btn renew-btn" bindtap="onSubscribe">
<text>续费订阅</text>
</button>
<text wx:if="{{expiredAt}}" class="hero-expired">已于 {{expiredAt}} 到期</text>
<text class="hero-expired">已于 {{expiredAt}} 到期</text>
</block>
<!-- 5. 未订阅付费频道 -->
<!-- 4. 未订阅付费频道 -->
<block wx:else>
<button class="hero-sub-btn" bindtap="onSubscribe">
<text>订阅频道</text>
@@ -61,7 +70,7 @@
<!-- 提示条:根据状态动态切换 -->
<!-- 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>
<view class="notice-info">
<text class="notice-title">VIP专属频道</text>
@@ -80,14 +89,14 @@
<text class="notice-action">续费 </text>
</view>
<!-- 付费频道未订阅(试听) -->
<view wx:elif="{{!isSubscribed && !isFree}}" class="trial-notice" bindtap="onSubscribe">
<text class="notice-icon">🔒</text>
<!-- 付费频道未订阅(全锁,无试听) -->
<view wx:elif="{{!canPlay && !isFree}}" class="trial-notice locked-notice" bindtap="onSubscribe">
<text class="notice-icon">🔐</text>
<view class="notice-info">
<text class="notice-title">试听模式</text>
<text class="notice-desc">可试听最新一期,订阅后解锁全部历史内容</text>
<text class="notice-title">频道未解锁</text>
<text class="notice-desc">订阅频道或开通全频道会员,即可畅听全部内容</text>
</view>
<text class="notice-action">订阅 </text>
<text class="notice-action">立即解锁 </text>
</view>
<!-- 内容列表标题 -->
@@ -119,18 +128,14 @@
</view>
</view>
<!-- 播放按钮 -->
<view wx:if="{{!item._isLocked}}" class="item-play-btn {{item._isThisPlaying ? 'active' : ''}}">
<image
wx:if="{{item._isThisPlaying && isPlaying}}"
src="/assets/icons/pause.svg"
class="item-play-icon"
/>
<image
wx:else
src="/assets/icons/play.svg"
class="item-play-icon"
/>
<!-- 播放 / 锁定按钮 -->
<view class="item-play-btn {{item._isThisPlaying ? 'active' : ''}} {{item._isLocked ? 'locked-btn' : ''}}">
<text wx:if="{{item._isLocked}}" class="lock-icon">🔒</text>
<view wx:elif="{{item._isThisPlaying && isPlaying}}" class="mini-pause">
<view class="mp-bar"></view>
<view class="mp-bar"></view>
</view>
<text wx:else class="play-tri">▶</text>
</view>
</view>
</view>
+52 -5
View File
@@ -86,6 +86,24 @@
color: rgba(255, 255, 255, 0.7);
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 / 到期)*/
.hero-badge {
@@ -286,10 +304,39 @@
border-color: transparent;
box-shadow: 0 4rpx 16rpx rgba(255, 157, 66, 0.3);
}
.item-play-icon {
width: 28rpx;
height: 28rpx;
/* 锁定状态的圆圈 */
.item-play-btn.locked-btn {
background: #F0F0F0;
border-color: #E0E0E0;
opacity: 0.6;
}
.item-play-btn.active .item-play-icon {
filter: brightness(0) invert(1);
.lock-icon {
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({
data: {
isVip: false,
vipPriceText: '',
categories: [],
activeFilter: '',
filteredDomains: [],
@@ -22,6 +23,7 @@ Page({
onShow() {
this._loadChannels()
this._loadVipPrice()
this._onSubChange = () => this._loadChannels()
this._onVipChange = () => this._loadChannels()
app.on('subscriptionChange', this._onSubChange)
@@ -69,6 +71,9 @@ Page({
var filtered = channels.map(function (ch) {
var isFree = ch.isFree === 1
var isVipOnly = ch.isVipOnly === 1
var isSubscribed = ch.hasSubscribed === 1
// VIP 用户可播放所有频道
var canPlay = gd.isVip || isFree || isSubscribed
// 最低价(分→元)
var lowestPrice = null
if (!isFree && !isVipOnly) {
@@ -83,10 +88,11 @@ Page({
}
}
return Object.assign({}, ch, {
_isSubscribed: ch.hasSubscribed === 1,
_isSubscribed: isSubscribed,
_isFree: isFree,
_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._isSubscribed) {
// 规则1canPlayVIP || isFree || hasSubscribed)→ 进频道详情
if (channel._canPlay) {
wx.navigateTo({ url: '/pages/channel-detail/index?id=' + id })
return
}
// 免费 → 直接进详情收听
if (channel._isFree) {
wx.navigateTo({ url: '/pages/channel-detail/index?id=' + id })
return
}
// VIP专享 → 引导 VIP 页
// 规则2:VIP专享 → 只能开通VIP,不可订阅
if (channel._isVipOnly) {
wx.navigateTo({ url: '/pages/vip/index' })
return
}
// 付费订阅 → 跳转 VIP/订阅页(channel 模式)
// 付费频道未订阅 → 跳转订阅页
var params = 'channelId=' + id
+ '&channelName=' + encodeURIComponent(channel.name || '')
+ '&monthlyPrice=' + (channel.monthlyPrice || 0)
+5 -13
View File
@@ -58,26 +58,18 @@
</view>
<!-- 行动按钮 -->
<!-- 已订阅 -->
<view wx:if="{{item._isSubscribed}}" class="sub-btn subscribed" bindtap="onAction" data-id="{{item.id}}">
<t-icon name="check-circle" size="28rpx" color="#999" />
<text>已订阅</text>
<!-- 可播放(VIP / 免费 / 已订阅)→ 收听 -->
<view wx:if="{{item._canPlay}}" class="sub-btn free" bindtap="onAction" data-id="{{item.id}}">
<text>▶ 收听</text>
</view>
<!-- 免费 → 收听 -->
<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专享 -->
<!-- VIP专享(且非VIP用户)-->
<view wx:elif="{{item._isVipOnly}}" class="sub-btn vip" bindtap="onAction" data-id="{{item.id}}">
<text>👑 VIP专享</text>
</view>
<!-- 付费订阅 -->
<view wx:else class="sub-btn paid" bindtap="onAction" data-id="{{item.id}}">
<t-icon name="shop" size="28rpx" color="#FFF" />
<text>订阅</text>
</view>
</view>
@@ -94,7 +86,7 @@
<text class="vip-banner-desc">立享极致畅听体验</text>
</view>
<view class="vip-banner-price">
<text>19.9元/月</text>
<text>{{vipPriceText || '会员价'}}</text>
</view>
</view>
+163 -47
View File
@@ -1,5 +1,5 @@
/**
* 收听历史 — 从后端获取历史列表
* 收听历史 — 历史 / 收藏 两 Tab
*/
const app = getApp()
const api = require('../../utils/api')
@@ -7,15 +7,28 @@ const util = require('../../utils/util')
Page({
data: {
filter: 'all',
cleared: false,
tab: 'history', // 'history' | 'favorite'
historyList: [],
isPlaying: false,
loading: true
},
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()
app.on('playerStateChange', this._onPlayerChange)
},
@@ -24,35 +37,52 @@ Page({
if (this._onPlayerChange) app.off('playerStateChange', this._onPlayerChange)
},
_refresh() {
if (this.data.cleared) return
setTab(e) {
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 gd = app.globalData
api.getHistoryList({ current: 1, pageSize: 30 }).then(function (res) {
if (res.code === 200 && res.data) {
var list = res.data.list || res.data || []
// 附带格式化信息
list = list.map(function (item) {
// 节目可能包含频道信息,根据后端返回结构适配
var channel = item.channel || item.program && item.program.channel || {}
var program = item.program || item
return Object.assign({}, program, {
_domainName: channel.name || program.channelName || '',
_icon: channel.icon || '🎵',
_bgColor: channel.bgColor || '#F0F0F0',
_coverUrl: (channel.cover && channel.cover.url) || channel.coverUrl || '',
_friendlyDate: util.getFriendlyDate(
program.createdAt ? program.createdAt.substring(0, 10) : ''
),
durationText: util.formatTime(program.duration || 0),
var list = (res.data.list || []).map(function (item) {
// program 嵌套在 item.program 下
var program = item.program || {}
// channel 可能为 nullchannel cover 在 program.cover
var channel = program.channel || {}
return {
// 播放所需字段
id: program.id,
title: program.title || '未知节目',
channelId: program.channelId || '',
content: program.content || '',
audioId: program.audioId || '',
// 用历史记录的 durationprogram.duration 可能是 0
duration: item.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(item.duration || 0),
// 播放进度
_progress: item.progress || 0,
_isThisPlaying: gd.activeContent && gd.activeContent.id === program.id
})
}
})
self.setData({ historyList: list, isPlaying: gd.isPlaying, loading: false })
} else {
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() {
var gd = app.globalData
var list = this.data.historyList.map(function (item) {
@@ -76,41 +137,96 @@ Page({
this.setData({ historyList: list, isPlaying: gd.isPlaying })
},
setFilter(e) {
this.setData({ filter: e.currentTarget.dataset.val })
},
onPlay(e) {
const id = e.currentTarget.dataset.id
const gd = app.globalData
// 从已加载数据中查找
var content = null
if (gd.activeContent && gd.activeContent.id === id) {
app.togglePlay()
return
}
// 从列表中找到基础信息
var base = null
for (var i = 0; i < this.data.historyList.length; i++) {
if (this.data.historyList[i].id === id) {
content = this.data.historyList[i]
base = this.data.historyList[i]
break
}
}
if (!content) return
if (!base) return
if (gd.activeContent && gd.activeContent.id === id) {
app.togglePlay()
} else {
app.playContent(content)
}
// 拉取完整节目详情(含 audio.url)再播放
wx.showLoading({ title: '加载中' })
api.getProgramDetail(id).then(function (res) {
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() {
const self = this
const tab = this.data.tab
const label = tab === 'history' ? '收听历史' : '全部收藏'
wx.showModal({
title: '提示',
content: '确定要清空所有收听历史吗?',
title: '清空' + label,
content: '确定要清空所有' + label + '吗?',
success(res) {
if (res.confirm) {
self.setData({ cleared: true, historyList: [] })
}
if (!res.confirm) return
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">
<!-- 筛选Tab + 清空按钮 -->
<!-- Tab 切换 + 清空 -->
<view class="filter-header">
<view class="filter-tabs">
<text
class="tab {{filter === 'all' ? 'active' : ''}}"
bindtap="setFilter"
data-val="all"
>全部片段</text>
<text
class="tab {{filter === 'subscribed' ? 'active' : ''}}"
bindtap="setFilter"
data-val="subscribed"
>仅看已订阅</text>
<view class="tab-item {{tab === 'history' ? 'active' : ''}}" bindtap="setTab" data-val="history">
<text class="tab-text">历史</text>
<view class="tab-underline"></view>
</view>
<view class="tab-item {{tab === 'favorite' ? 'active' : ''}}" bindtap="setTab" data-val="favorite">
<text class="tab-text">收藏</text>
<view class="tab-underline"></view>
</view>
</view>
<view class="clear-btn tap-active" bindtap="onClear">
<text class="clear-icon">🗑</text>
<view class="clear-btn tap-active" bindtap="onClear" wx:if="{{historyList.length > 0}}">
<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>
</view>
</view>
<!-- 历史列表 -->
<!-- 列表 -->
<view class="list-area">
<!-- 空状态 -->
<view wx:if="{{cleared || historyList.length === 0}}" class="empty-state">
<view class="empty-icon-wrap">
<text class="empty-emoji">📭</text>
<!-- 加载骨架 -->
<view wx:if="{{loading}}" class="skeleton-wrap">
<view wx:for="{{[1,2,3]}}" wx:key="*this" class="skeleton-item">
<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>
<text class="empty-text">暂无收听历史</text>
</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
wx:for="{{historyList}}"
wx:key="id"
class="history-item card"
class="history-item"
bindtap="onPlay"
data-id="{{item.id}}"
>
<!-- 频道图标 -->
<!-- 频道/节目图标 -->
<view class="h-icon" style="background: {{item._bgColor}};">
<image wx:if="{{item._coverUrl}}" src="{{item._coverUrl}}" class="h-cover-img" mode="aspectFill" />
<text wx:else class="h-emoji">{{item._icon}}</text>
<text class="h-emoji">{{item._icon}}</text>
</view>
<!-- 信息 -->
<!-- 文字信息 -->
<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-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 wx:if="{{item._isThisPlaying && isPlaying}}" class="playing-indicator">
<view class="bar bar-1"></view>
<view class="bar bar-2"></view>
<view class="bar bar-3"></view>
<!-- 播放指示 / 播放按钮 -->
<view class="h-right">
<view wx:if="{{item._isThisPlaying && isPlaying}}" class="playing-indicator">
<view class="bar bar-1"></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 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>
+296 -71
View File
@@ -1,106 +1,239 @@
/* 收听历史样式 */
/* 收听历史 / 收藏 */
.history-page {
min-height: 100vh;
background: var(--color-bg-page);
background: #F7F3EE;
}
/* 筛选头部 */
/* ── Tab 头部 ── */
.filter-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 32rpx;
padding: 0 32rpx;
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 {
display: flex;
align-items: center;
}
.clear-icon {
font-size: 22rpx;
margin-right: 6rpx;
gap: 8rpx;
padding: 12rpx 0;
}
.clear-text {
font-size: 22rpx;
color: #999;
font-size: 24rpx;
color: #BBBBBB;
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 {
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 {
display: flex;
flex-direction: column;
align-items: center;
padding: 160rpx 0;
opacity: 0.5;
padding: 80rpx 48rpx 48rpx;
}
.empty-icon-wrap {
width: 128rpx;
height: 128rpx;
border-radius: 50%;
background: #F5F5F5;
.empty-icon {
font-size: 96rpx;
margin-bottom: 32rpx;
filter: drop-shadow(0 8rpx 16rpx rgba(255,157,66,0.2));
}
.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;
align-items: center;
justify-content: center;
margin-bottom: 24rpx;
box-shadow: 0 8rpx 24rpx rgba(255,157,66,0.35);
}
.empty-emoji {
font-size: 48rpx;
.btn-text {
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;
color: #999;
font-weight: 500;
font-weight: 600;
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 {
display: flex;
align-items: center;
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 {
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 {
width: 88rpx;
height: 88rpx;
border-radius: 24rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.h-emoji {
font-size: 40rpx;
}
.h-emoji { font-size: 42rpx; }
.h-info {
flex: 1;
@@ -111,50 +244,73 @@
display: block;
font-size: 20rpx;
font-weight: 700;
color: #999;
letter-spacing: 2rpx;
color: #FF9D42;
letter-spacing: 1rpx;
margin-bottom: 6rpx;
text-transform: uppercase;
}
.h-title {
display: block;
font-size: 26rpx;
font-size: 28rpx;
font-weight: 700;
color: #333;
color: #2C1A08;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: 'PingFang SC', sans-serif;
}
.h-title.text-primary {
color: var(--color-primary);
.h-title.text-primary { color: #FF9D42; }
.h-meta-row {
display: flex;
align-items: center;
flex-wrap: wrap;
margin-top: 8rpx;
}
.h-meta {
display: block;
font-size: 20rpx;
color: #BBB;
margin-top: 6rpx;
display: inline;
font-size: 22rpx;
color: #C8BAAA;
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 {
display: flex;
align-items: flex-end;
gap: 4rpx;
width: 48rpx;
height: 48rpx;
width: 40rpx;
height: 40rpx;
justify-content: center;
flex-shrink: 0;
padding-bottom: 4rpx;
}
.bar {
width: 6rpx;
border-radius: 4rpx;
background: var(--color-primary);
background: #FF9D42;
animation: bounce 0.6s ease-in-out infinite;
}
.bar-1 { height: 24rpx; animation-delay: 0s; }
.bar-2 { height: 40rpx; animation-delay: 0.1s; }
.bar-3 { height: 16rpx; animation-delay: 0.2s; }
@keyframes bounce {
0%, 100% { transform: scaleY(0.4); }
50% { transform: scaleY(1); }
@@ -164,14 +320,83 @@
width: 56rpx;
height: 56rpx;
border-radius: 50%;
border: 2rpx solid #EEE;
background: #FFF4E8;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.play-mini-icon {
width: 20rpx;
height: 20rpx;
opacity: 0.4;
font-size: 22rpx;
color: #FF9D42;
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 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({
data: {
greetingSub: '',
// 同步计算,第一帧就正确,避免闪烁
greetingSub: _computeGreeting(),
statusBarHeight: app.globalData.statusBarHeight || 0,
locationName: '',
weather: null,
dateDisplay: '',
@@ -23,7 +50,6 @@ Page({
freeChannels: [],
isPlaying: false,
isVip: false,
statusBarHeight: 0,
loadingSub: true,
loadingFree: true
},
@@ -33,13 +59,13 @@ Page({
onShow() {
const gd = app.globalData
this.setData({
greetingSub: this._getGreeting(),
greetingSub: _computeGreeting(), // 每次进页随机刷新文案
dateDisplay: util.getDateDisplay(),
weekDay: util.getWeekDay(),
locationName: gd.locationName || '',
weather: gd.weather || null,
isVip: gd.isVip || false,
statusBarHeight: gd.statusBarHeight || 0
isVip: gd.isVip || false
// statusBarHeight 小程序运行期间不会变化,无需重设
})
this._loadAll()
this._bindEvents()
@@ -96,18 +122,24 @@ Page({
},
/**
* 拉取免费频道列表
* 首次无数据时显示骨架屏,后续静默刷新
* 拉取频道列表Section 2
* VIP 用户:全部频道;普通用户:仅免费频道
*/
_loadFreeChannels() {
const self = this
const gd = app.globalData
const isFirstLoad = self.data.freeChannels.length === 0
if (isFirstLoad) {
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) {
if (res.code !== 200 || !res.data) {
self.setData({ freeChannels: [], loadingFree: false })
@@ -115,23 +147,21 @@ Page({
}
const list = res.data.list || []
const freeChannels = list.map(function (ch) {
return {
id: ch.id,
name: ch.name || '未命名',
// cover 直接是 emoji 字符串
cover: ch.cover || '📻',
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 })
})
.catch(function (err) {
console.error('[首页] 免费频道失败', err)
console.error('[首页] 频道加载失败', err)
self.setData({ loadingFree: false })
})
},
@@ -261,61 +291,5 @@ Page({
},
// ===================== 工具方法 =====================
/** 根据时间段返回问候语 */
_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)]
}
// _computeGreeting 已提至模块级,可在 data 初始化时直接调用
})
+16 -3
View File
@@ -41,6 +41,16 @@
>
<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: 我的订阅 ─── -->
<view class="section-header">
<view class="section-title-wrap">
@@ -152,8 +162,8 @@
<view class="section-header section-header-free">
<view class="section-title-wrap">
<text class="section-dot dot-free"></text>
<text class="section-title">免费频道</text>
<text class="section-subtitle">无需订阅,随时收听</text>
<text class="section-title">{{isVip ? '全部频道' : '免费频道'}}</text>
<text class="section-subtitle">{{isVip ? '会员全开放,点即收听' : '无需订阅,随时收听'}}</text>
</view>
<view class="section-action tap-active" bindtap="goDiscover">
<text class="section-action-text">全部</text>
@@ -191,7 +201,10 @@
<text class="free-emoji">{{item.cover || '📻'}}</text>
</view>
<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>
</scroll-view>
+33
View File
@@ -118,6 +118,39 @@
.content-area {
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 ========== */
-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,
isPlaying: false,
isVip: false,
isLiked: false,
currentTime: 0,
duration: 0,
currentTimeText: '00:00',
@@ -20,16 +21,13 @@ Page({
displayDate: '',
playbackRate: 1.0,
statusBarHeight: 0,
showTranscript: false, // 封面 ⇔ 文案切换
showSpeedSheet: false,
speedItems: [
{ label: '0.5x' },
{ label: '0.75x' },
{ label: '1.0x' },
{ label: '1.25x' },
{ label: '1.5x' },
{ label: '2.0x' }
]
showTranscript: false,
// 评论弹层
showComments: false,
commentList: [],
commentText: '',
commentLoading: false,
submitting: false
},
_isSeeking: false,
@@ -65,6 +63,9 @@ Page({
app.on('playerStateChange', this._onPlayerChange)
app.on('timeUpdate', this._onTimeUpdate)
// 查询当前节目点赞状态
this._loadLikeStatus()
},
onHide() {
@@ -108,7 +109,7 @@ Page({
},
/**
* 获取频道信息 — 从后端 API 获取
* 获取频道信息
*/
_updateDomain() {
const content = this.data.activeContent
@@ -116,14 +117,12 @@ Page({
var channelId = content.channelId || (content.channel && content.channel.id)
if (!channelId) {
// 如果节目数据中直接包含 channel 信息
if (content.channel) {
this.setData({ domain: content.channel })
}
return
}
// 如果已经加载过且 channelId 没变,跳过
if (this.data.domain && this.data.domain.id === channelId) return
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() {
const content = this.data.activeContent
if (!content) return
api.toggleLike({ contentId: content.id }).then(function (res) {
wx.showToast({ title: res.code === 200 ? '已收藏 ♥' : '操作失败', icon: 'none' })
const self = this
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 () {
self.setData({ isLiked: wasLiked })
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() {
wx.showToast({ title: '分享功能开发中', icon: 'none' })
},
+65 -14
View File
@@ -2,7 +2,7 @@
<page-meta page-style="overflow:hidden; background:#1A1208;" />
<view class="player-page">
<!-- 状态栏占位(custom 导航模式必须手动留出状态栏高度) -->
<!-- 状态栏占位 -->
<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%);">
</view>
<!-- 顶部:返回 + 标题 + 分享 -->
<!-- 顶部:返回 + 标题 + 评论 -->
<view class="top-bar">
<view class="top-btn tap-active" bindtap="goBack">
<text class="top-back"></text>
@@ -19,8 +19,8 @@
<text class="top-label">正在播放</text>
<text class="top-title">{{activeContent.title || '加载中...'}}</text>
</view>
<view class="top-btn tap-active" bindtap="onShare">
<text class="top-share"></text>
<view class="top-btn tap-active" bindtap="onOpenComments">
<text class="top-comment-icon">💬</text>
</view>
</view>
@@ -36,7 +36,7 @@
<view class="ripple-ring" style="animation-delay: 1.4s;"></view>
</view>
<!-- 大 emoji,无卡片背景 -->
<!-- 大 emoji -->
<text class="cover-emoji {{isPlaying ? 'emoji-active' : ''}}">📻</text>
<!-- 频道名 -->
@@ -58,11 +58,11 @@
</view>
</view>
<!-- 日期 + like 同行,进度条上方 -->
<!-- 日期 + like 同行 -->
<view class="date-like-row">
<text class="date-text">{{displayDate}}</text>
<view class="like-btn-inline tap-active" bindtap="onLike">
<text class="like-icon">♡</text>
<text class="like-icon {{isLiked ? 'liked' : ''}}">{{isLiked ? '♥' : '♡'}}</text>
</view>
</view>
@@ -107,11 +107,62 @@
</view>
</view>
<!-- 倍速选择面板 -->
<t-action-sheet
visible="{{showSpeedSheet}}"
items="{{speedItems}}"
bind:selected="onSpeedSelect"
bind:cancel="onSpeedCancel"
/>
<!-- 评论弹层 -->
<view class="comment-mask" wx:if="{{showComments}}" bindtap="onCloseComments"></view>
<view class="comment-sheet {{showComments ? 'sheet-up' : ''}}">
<view class="sheet-handle"></view>
<view class="sheet-header">
<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>
+147
View File
@@ -320,3 +320,150 @@
background: #FFF8EE;
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: {
isVip: false,
userInfo: null,
subscribedData: [],
menuItems: [
{ id: 'vip', label: '会员中心', icon: '👑', desc: '未开通', highlight: true },
{ id: 'help', label: '帮助与反馈', icon: '❓', desc: '', highlight: false },
@@ -39,24 +38,6 @@ Page({
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状态
var menuItems = this.data.menuItems.slice()
menuItems[0].desc = gd.isVip ? '已开通' : '未开通'
@@ -64,24 +45,6 @@ Page({
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) {
const id = e.currentTarget.dataset.id
if (id === 'vip') {
+46 -58
View File
@@ -1,85 +1,73 @@
<!-- 个人中心 —— 用户信息 + 订阅管理 + 菜单 -->
<!-- 个人中心 - 全新极简温暖主题 -->
<view class="profile-page">
<!-- 橙色头部背景 -->
<view class="header-bg">
<view class="user-info">
<!-- 头像 -->
<view class="avatar-wrap">
<!-- 沉浸式头部区域 -->
<view class="header-section">
<!-- 装饰背景 -->
<view class="header-bg-shape"></view>
<!-- 用户信息块 -->
<view class="user-info-box">
<view class="avatar-container">
<image wx:if="{{userInfo && userInfo.avatarId}}" src="{{userInfo.avatar.url || ''}}" class="avatar-img" mode="aspectFill" />
<text wx:else class="avatar-emoji">😊</text>
</view>
<text class="user-name">
{{userInfo.nickName || userInfo.name || '微信用户'}}
<text wx:if="{{isVip}}" class="vip-crown">👑</text>
</text>
<text class="user-desc">
{{isVip ? '全频道会员' : '免费用户 · 开通会员畅听全频道'}}
</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 class="user-details">
<view class="name-line">
<text class="user-name">{{userInfo.nickName || userInfo.name || '你好,收听者'}}</text>
<view wx:if="{{isVip}}" class="vip-badge">
<text class="vip-badge-icon">👑</text>
<text class="vip-badge-text">VIP会员</text>
</view>
</view>
<text class="user-id">今日也要元气满满哦</text>
</view>
</view>
</view>
<!-- 菜单列表 -->
<view class="menu-card-wrap">
<view class="card menu-card">
<!-- 内容区 -->
<view class="content-section">
<!-- 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
wx:for="{{menuItems}}"
wx:key="id"
class="menu-item tap-active"
class="menu-row tap-active"
bindtap="onMenuTap"
data-id="{{item.id}}"
>
<view class="menu-item-left">
<view class="menu-icon-wrap {{item.highlight ? 'highlight' : ''}}">
<view class="menu-left">
<view class="menu-icon-box {{item.id === 'vip' ? 'is-vip-icon' : ''}}">
<text class="menu-emoji">{{item.icon}}</text>
</view>
<text class="menu-label">{{item.label}}</text>
<text class="menu-title">{{item.label}}</text>
</view>
<view class="menu-item-right">
<text class="menu-desc {{item.highlight ? 'highlight' : ''}}">{{item.desc}}</text>
<view class="menu-right">
<text class="menu-status {{item.highlight ? 'highlight-text' : ''}}">{{item.desc}}</text>
<text class="menu-arrow"></text>
</view>
</view>
</view>
</view>
<view style="height: 200rpx;"></view>
<!-- 底部留白以防遮挡播放器 -->
<view class="bottom-spacer"></view>
<global-player />
</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 {
min-height: 100vh;
background: var(--color-bg-page);
background-color: #F9F9F9;
position: relative;
overflow-x: hidden;
}
/* 橙色头部 */
.header-bg {
background: #F38600;
border-radius: 0 0 48rpx 48rpx;
padding-bottom: 200rpx;
/* 沉浸式头部 */
.header-section {
position: relative;
width: 100%;
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;
flex-direction: column;
align-items: center;
padding: 32rpx 40rpx;
padding: 0 48rpx;
}
.avatar-wrap {
width: 128rpx;
height: 128rpx;
.avatar-container {
width: 140rpx;
height: 140rpx;
border-radius: 50%;
border: 6rpx solid rgba(255, 255, 255, 0.3);
background: linear-gradient(135deg, #FFB366, #FFE0B2);
border: 6rpx solid rgba(255, 255, 255, 0.6);
background: #FFF;
box-shadow: 0 12rpx 32rpx rgba(243, 134, 0, 0.15);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20rpx;
overflow: hidden;
flex-shrink: 0;
}
.avatar-emoji {
font-size: 64rpx;
}
.avatar-img {
width: 100%;
height: 100%;
border-radius: 50%;
}
.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;
object-fit: cover;
}
/* 订阅卡片 */
.sub-card-wrap {
.avatar-emoji {
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;
margin-top: -140rpx;
margin-top: -30rpx;
position: relative;
z-index: 10;
}
.sub-card {
padding: 32rpx;
}
/* 空状态 */
.sub-empty {
text-align: center;
padding: 40rpx 0;
}
.sub-empty-text {
display: block;
font-size: 26rpx;
color: #999;
margin-bottom: 24rpx;
}
/* 订阅列表项 */
.sub-item {
/* VIP宣发引导卡片 */
.vip-promo-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx;
background: linear-gradient(135deg, #FFEFD5 0%, #FFE4B5 100%);
padding: 32rpx 40rpx;
border-radius: 32rpx;
background: #FEFEFE;
border: 1rpx solid rgba(0,0,0,0.04);
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.02);
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;
box-shadow: 0 8rpx 24rpx rgba(217, 119, 6, 0.08);
margin-bottom: 32rpx;
border: 1rpx solid rgba(255, 255, 255, 0.5);
}
.sub-del {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: #F5F5F5;
border: 1rpx solid #EEE;
.promo-left {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.del-icon {
font-size: 24rpx;
}
.sub-notice {
display: block;
font-size: 20rpx;
color: rgba(255, 157, 66, 0.7);
text-align: center;
margin-top: 12rpx;
.promo-icon {
font-size: 48rpx;
margin-right: 24rpx;
}
.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;
}
/* 菜单卡片 */
.menu-card-wrap {
padding: 24rpx 32rpx 0;
.promo-btn {
background: #333333;
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;
align-items: center;
justify-content: space-between;
padding: 28rpx 32rpx;
border-bottom: 1rpx solid rgba(0,0,0,0.03);
padding: 32rpx 40rpx;
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;
align-items: center;
}
.menu-icon-wrap {
width: 52rpx;
height: 52rpx;
border-radius: 50%;
background: #F5F5F5;
.menu-icon-box {
width: 60rpx;
height: 60rpx;
background: #F8F9FA;
border-radius: 20rpx;
display: flex;
align-items: 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 {
font-size: 24rpx;
font-size: 32rpx;
}
.menu-label {
font-size: 28rpx;
font-weight: 700;
color: #333;
letter-spacing: 2rpx;
.menu-title {
font-size: 30rpx;
font-weight: 600;
color: #333333;
}
.menu-item-right {
.menu-right {
display: flex;
align-items: center;
gap: 12rpx;
}
.menu-desc {
font-size: 22rpx;
color: #999;
font-weight: 500;
.menu-status {
font-size: 24rpx;
color: #999999;
margin-right: 12rpx;
}
.menu-desc.highlight {
.menu-status.highlight-text {
color: #D97706;
font-weight: 600;
}
.menu-arrow {
font-size: 28rpx;
color: #CCC;
font-size: 32rpx;
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 annual = (parseFloat(options.annualPrice) || 0) / 100
// 默认选中包年,否则最合算的
let defaultPlan = 'monthly'
let defaultPrice = monthly
if (annual > 0) { defaultPlan = 'annual'; defaultPrice = annual }
@@ -59,15 +58,42 @@ Page({
})
} else {
// ─── VIP 会员模式 ───
const gd = app.globalData
this.setData({
mode: 'vip',
isVip: app.globalData.isVip,
isVip: gd.isVip,
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() {
if (this.data.mode === 'vip') {
this.setData({ isVip: app.globalData.isVip })
@@ -95,20 +121,42 @@ Page({
const { mode, selectedPlan, currentPrice, channelId } = this.data
if (mode === 'vip') {
// ── VIP 全频道(模拟,后续接入时替换) ──
wx.showModal({
title: '确认支付',
content: `即将支付 ¥${currentPrice} 开通全频道会员`,
success(res) {
if (res.confirm) {
app.upgradeVip()
wx.showToast({ title: '开通成功!', icon: 'success' })
setTimeout(function () {
self.setData({ isVip: true })
}, 500)
// ── VIP 永久会员:调后端预支付接口 ──
wx.showLoading({ title: '获取支付信息...' })
api.initiateVipPayment()
.then(function (res) {
if (res.code !== 200 || !res.data || !res.data.payments) {
wx.hideLoading()
wx.showToast({ title: res.msg || '获取支付信息失败', icon: 'none' })
return
}
}
})
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
}
@@ -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() {
wx.navigateBack()
}
+55 -25
View File
@@ -2,12 +2,56 @@
<page-meta page-style="overflow: hidden;" />
<view class="vip-page">
<!-- ── 已是VIP直接返回 ── -->
<!-- ── 已是 VIP展示会员权益页 ── -->
<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>
<button class="done-back-btn" bindtap="goBack">返回</button>
<!-- 头部云光装饰 -->
<view class="vip-glow"></view>
<!-- 安全区域占位 -->
<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>
<!-- ── 主Scroll区域 ── -->
@@ -23,7 +67,7 @@
<view class="vip-hero">
<text class="vip-hero-title">{{mode === 'channel' ? channelName : '开通全频道会员'}}</text>
<text class="vip-hero-desc">
{{mode === 'channel' ? '选择适合你的订阅方案,随时随地收听' : '解锁全部频道,告别无聊早晨'}}
{{mode === 'channel' ? '选择适合你的订阅方案,随时随地收听' : '一次开通,永久解锁全部频道'}}
</text>
</view>
@@ -51,20 +95,6 @@
<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 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>
@@ -77,14 +107,14 @@
bindtap="selectPlan"
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">
<text class="plan-name">全频道连续包月</text>
<text class="plan-desc">自动续费,随时可取消</text>
<text class="plan-name">永久会员</text>
<text class="plan-desc">{{vipRemark || '一次购买,永久畅听全部频道'}}</text>
</view>
<view class="plan-price">
<text class="price-amount"><text class="price-symbol">¥</text>19.9</text>
<text class="price-original">¥29.9</text>
<text class="price-amount"><text class="price-symbol">¥</text>{{vipPrice || currentPrice}}</text>
<text class="price-original" wx:if="{{vipOriginalPrice}}">¥{{vipOriginalPrice}}</text>
</view>
</view>
</view>
+134 -21
View File
@@ -9,39 +9,152 @@
overflow: hidden;
}
/* 已是VIP */
/* 已是VIP 页面整体 */
.vip-done {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
padding: 40rpx;
background: #FFFFFF;
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
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 {
font-size: 36rpx;
font-weight: 700;
color: #333;
margin-bottom: 16rpx;
font-size: 44rpx;
font-weight: 800;
color: #FFF;
letter-spacing: 4rpx;
margin-bottom: 24rpx;
text-shadow: 0 4rpx 16rpx rgba(0,0,0,0.5);
}
.vip-done-desc {
font-size: 28rpx;
color: #999;
margin-bottom: 48rpx;
}
.done-back-btn {
padding: 16rpx 48rpx;
background: #F5F5F5;
.vip-expire-badge {
background: rgba(255, 255, 255, 0.1);
padding: 12rpx 32rpx;
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;
}
/* 权益网格 */
.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;
color: #666;
line-height: 1.5;
}
.done-back-btn::after { border: none; }