first commit

This commit is contained in:
Blizzard
2026-03-05 09:08:21 +08:00
commit 0a61c4ddec
2189 changed files with 38610 additions and 0 deletions
+194
View File
@@ -0,0 +1,194 @@
/**
* 频道详情 — 从后端获取频道信息 + 节目列表
*
* 性能优化:
* - 使用 channel detail 返回的 hasSubscribed 字段,不再单独请求订阅列表
* - 展示 expiredAt 字段标记订阅到期时间
*/
const app = getApp()
const api = require('../../utils/api')
const util = require('../../utils/util')
Page({
data: {
domain: {},
isSubscribed: false,
isExpired: false, // 订阅是否已过期
expiredAt: '', // 到期时间(格式化)
isFree: false, // 快捷字段,避免模板 domain.isFree
isVipOnly: false,
domainContents: [],
isPlaying: false,
loading: true
},
onLoad(options) {
const id = options.id
this._domainId = id
this._loadChannelDetail()
},
onShow() {
this._loadPrograms()
this._onPlayerChange = () => this._updatePlayState()
this._onSubChange = () => this._loadPrograms()
app.on('playerStateChange', this._onPlayerChange)
app.on('subscriptionChange', this._onSubChange)
},
onHide() {
if (this._onPlayerChange) app.off('playerStateChange', this._onPlayerChange)
if (this._onSubChange) app.off('subscriptionChange', this._onSubChange)
},
/**
* 加载频道详情
* hasSubscribed / expiredAt 都从这个接口返回
*/
_loadChannelDetail() {
const self = this
api.getChannelDetail(this._domainId).then(function (res) {
if (res.code === 200 && res.data) {
const ch = res.data
const isFree = ch.isFree === 1
// 免费频道:不关心订阅状态和到期时间
var expiredAt = ''
var isExpired = false
var isSubscribed = false
if (!isFree) {
if (ch.expiredAt) {
expiredAt = ch.expiredAt.substring(0, 10).replace(/-/g, '.')
isExpired = new Date(ch.expiredAt) < new Date()
}
isSubscribed = ch.hasSubscribed === 1 && !isExpired
}
self.setData({
domain: ch,
isSubscribed,
isExpired,
expiredAt,
isFree,
isVipOnly: ch.isVipOnly === 1
})
}
}).catch(function (err) {
console.error('[ChannelDetail] 加载频道详情失败:', err)
})
},
/**
* 加载节目列表(静默刷新,不重复请求订阅状态)
*/
_loadPrograms() {
const self = this
api.getProgramList({ channelId: self._domainId, current: 1, pageSize: 50 })
.then(function (progRes) {
var contents = []
if (progRes.code === 200 && progRes.data) {
contents = progRes.data.list || progRes.data || []
}
var gd = app.globalData
var isSubscribed = self.data.isSubscribed
var isFree = self.data.domain.isFree === 1
var total = contents.length
contents = contents.map(function (item, idx) {
return Object.assign({}, item, {
_displayIndex: String(total - idx).padStart(2, '0'),
_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
})
})
self.setData({
domainContents: contents,
isPlaying: gd.isPlaying,
loading: false
})
})
.catch(function (err) {
console.error('[ChannelDetail] 加载节目失败:', err)
self.setData({ loading: false })
})
},
/**
* 仅更新播放状态(不重新请求)
*/
_updatePlayState() {
var gd = app.globalData
var contents = this.data.domainContents.map(function (item) {
return Object.assign({}, item, {
_isThisPlaying: gd.activeContent && gd.activeContent.id === item.id
})
})
this.setData({ domainContents: contents, isPlaying: gd.isPlaying })
},
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' })
return
}
var content = null
for (var i = 0; i < this.data.domainContents.length; i++) {
if (this.data.domainContents[i].id === id) {
content = this.data.domainContents[i]
break
}
}
if (!content) return
if (gd.activeContent && gd.activeContent.id === id) {
app.togglePlay()
} else {
app.playContent(content)
}
},
onSubscribe() {
const id = this._domainId
const domain = this.data.domain
// 已订阅 → 已在订阅中,无需操作
if (this.data.isSubscribed) {
wx.showToast({ title: '您已订阅该频道', icon: 'none' })
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 })
},
goBack() {
wx.navigateBack()
}
})
+7
View File
@@ -0,0 +1,7 @@
{
"usingComponents": {
"global-player": "/components/global-player/index",
"t-message": "tdesign-miniprogram/message/message"
},
"navigationBarTitleText": "频道详情"
}
+142
View File
@@ -0,0 +1,142 @@
<!-- 频道详情 —— 频道信息头部 + 往期内容列表 -->
<view class="detail-page">
<!-- 频道信息头部 -->
<view class="hero" style="background: linear-gradient(135deg, {{domain.bgColor || '#FF9D42'}}, {{domain.bgColorEnd || '#FFB366'}});">
<!-- 频道信息 -->
<view class="hero-content">
<text class="hero-icon">{{domain.cover || domain.icon || '📻'}}</text>
<text class="hero-name">{{domain.name}}</text>
<text class="hero-tag">{{domain.tag || domain.description || ''}}</text>
<!-- ═══ 按钮区:根据频道类型和订阅状态分情况 ═══ -->
<!-- 1. 免费频道 -->
<block wx:if="{{isFree}}">
<view class="hero-badge free-badge">🎁 永久免费</view>
<button class="hero-sub-btn free-btn" bindtap="onSubscribe">
<text>▶ 开始收听</text>
</button>
</block>
<!-- 2. VIP专享 -->
<block wx:elif="{{isVipOnly}}">
<view class="hero-badge vip-badge">👑 VIP专享</view>
<button class="hero-sub-btn vip-btn" bindtap="onSubscribe">
<text>开通 VIP 解锁</text>
</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. 订阅已过期(重新订阅) -->
<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>
</block>
<!-- 5. 未订阅付费频道 -->
<block wx:else>
<button class="hero-sub-btn" bindtap="onSubscribe">
<text>订阅频道</text>
</button>
</block>
</view>
<!-- 底部波浪装饰 -->
<view class="hero-wave"></view>
</view>
<!-- 内容区域 -->
<view class="content-area">
<!-- 提示条:根据状态动态切换 -->
<!-- VIP专享未开通 -->
<view wx:if="{{isVipOnly && !isSubscribed}}" class="trial-notice vip-notice" bindtap="onSubscribe">
<text class="notice-icon">👑</text>
<view class="notice-info">
<text class="notice-title">VIP专属频道</text>
<text class="notice-desc">开通全频道会员,畅享本频道全部内容及免广告特权</text>
</view>
<text class="notice-action">去开通 </text>
</view>
<!-- 订阅已过期(非免费频道) -->
<view wx:elif="{{isExpired && !isFree}}" class="trial-notice expired-notice" bindtap="onSubscribe">
<text class="notice-icon">⏰</text>
<view class="notice-info">
<text class="notice-title">订阅已到期</text>
<text class="notice-desc">您的订阅已于 {{expiredAt}} 到期,续费后可继续收听全部内容</text>
</view>
<text class="notice-action">续费 </text>
</view>
<!-- 付费频道未订阅(试听) -->
<view wx:elif="{{!isSubscribed && !isFree}}" class="trial-notice" bindtap="onSubscribe">
<text class="notice-icon">🔒</text>
<view class="notice-info">
<text class="notice-title">试听模式</text>
<text class="notice-desc">可试听最新一期,订阅后解锁全部历史内容</text>
</view>
<text class="notice-action">订阅 </text>
</view>
<!-- 内容列表标题 -->
<view class="list-header">
<text class="list-title">内容列表</text>
<view class="list-count">{{domainContents.length}} 期</view>
</view>
<!-- 内容列表 -->
<view class="list">
<view
wx:for="{{domainContents}}"
wx:key="id"
class="list-item {{item._isThisPlaying ? 'playing' : ''}} {{item._isLocked ? 'locked' : ''}}"
bindtap="onPlayItem"
data-id="{{item.id}}"
data-idx="{{index}}"
>
<!-- 序号 -->
<text class="item-index">{{item._displayIndex}}</text>
<!-- 信息 -->
<view class="item-info">
<text class="item-title {{item._isThisPlaying ? 'text-primary' : ''}}">{{item.title}}</text>
<view class="item-meta">
<text>{{item._dateDot}}</text>
<text class="meta-dot">·</text>
<text>{{item.durationText}}</text>
</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>
</view>
</view>
</view>
<view style="height: 200rpx;"></view>
<global-player />
<t-message id="t-message" />
</view>
+295
View File
@@ -0,0 +1,295 @@
/* 频道详情页样式 */
.detail-page {
min-height: 100vh;
background: #FCFCFC;
}
/* 沉浸式头部 */
.hero {
width: 100%;
position: relative;
overflow: hidden;
}
.back-btn {
position: absolute;
left: 32rpx;
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.back-arrow {
font-size: 48rpx;
color: #FFF;
font-weight: 300;
margin-top: -4rpx;
}
.hero-content {
display: flex;
flex-direction: column;
align-items: center;
padding-bottom: 100rpx;
position: relative;
z-index: 2;
}
.hero-icon {
font-size: 120rpx;
margin-bottom: 24rpx;
text-shadow: 0 8rpx 16rpx rgba(0, 0, 0, 0.2);
}
.hero-name {
font-size: 52rpx;
font-weight: 800;
color: #FFF;
letter-spacing: -2rpx;
margin-bottom: 12rpx;
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.15);
}
.hero-tag {
font-size: 26rpx;
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 40rpx;
}
.hero-sub-btn {
padding: 16rpx 56rpx;
border-radius: 999rpx;
font-size: 26rpx;
font-weight: 700;
background: #FFF;
color: #333;
border: none;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
z-index: 10;
}
.hero-sub-btn::after { border: none; }
.hero-sub-btn:active { transform: scale(0.97); }
.hero-sub-btn.subscribed {
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
color: #FFF;
border: 2rpx solid rgba(255, 255, 255, 0.3);
box-shadow: none;
}
.hero-expired {
display: block;
margin-top: 12rpx;
font-size: 20rpx;
color: rgba(255, 255, 255, 0.7);
letter-spacing: 0.5rpx;
}
/* 徽标(免费 / VIP / 到期)*/
.hero-badge {
display: inline-block;
padding: 8rpx 24rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 700;
margin-bottom: 16rpx;
}
.free-badge { background: rgba(46, 204, 113, 0.25); color: #FFF; border: 1rpx solid rgba(255,255,255,0.3); }
.vip-badge { background: rgba(251, 191, 36, 0.3); color: #FFC; border: 1rpx solid rgba(255,255,255,0.3); }
.expired-badge { background: rgba(239, 68, 68, 0.3); color: #FFF; border: 1rpx solid rgba(255,255,255,0.3); }
/* 按钮变体 */
.hero-sub-btn.free-btn { background: #2ECC71; color: #FFF; box-shadow: 0 8rpx 24rpx rgba(46,204,113,0.3); }
.hero-sub-btn.vip-btn { background: linear-gradient(135deg, #FBBF24, #D97706); color: #1F2937; box-shadow: 0 8rpx 24rpx rgba(251,191,36,0.35); }
.hero-sub-btn.renew-btn { background: linear-gradient(135deg, #FF9D42, #FF7832); color: #FFF; box-shadow: 0 8rpx 24rpx rgba(255,120,50,0.35); }
/* 波浪 */
.hero-wave {
position: absolute;
bottom: -4rpx;
left: 0;
right: 0;
height: 64rpx;
background: #FCFCFC;
border-radius: 100% 100% 0 0;
}
/* 内容区 */
.content-area {
padding: 0 32rpx;
position: relative;
z-index: 10;
}
/* 试听提示 */
.trial-notice {
display: flex;
align-items: flex-start;
background: var(--color-primary-light);
border: 1rpx solid rgba(255, 157, 66, 0.2);
border-radius: 24rpx;
padding: 24rpx;
margin-bottom: 32rpx;
}
/* VIP提示(金色) */
.vip-notice {
background: rgba(251, 191, 36, 0.08);
border-color: rgba(251, 191, 36, 0.3);
}
/* 到期提示(红色) */
.expired-notice {
background: rgba(239, 68, 68, 0.06);
border-color: rgba(239, 68, 68, 0.2);
}
.notice-action {
flex-shrink: 0;
font-size: 24rpx;
font-weight: 700;
color: var(--color-primary);
margin-left: 8rpx;
align-self: center;
}
.notice-icon {
font-size: 32rpx;
margin-right: 16rpx;
flex-shrink: 0;
margin-top: 4rpx;
}
.notice-info {
flex: 1;
}
.notice-title {
display: block;
font-size: 24rpx;
font-weight: 700;
color: #333;
}
.notice-desc {
display: block;
font-size: 22rpx;
color: #999;
margin-top: 6rpx;
line-height: 1.5;
}
/* 列表头 */
.list-header {
display: flex;
align-items: center;
padding: 16rpx 0 24rpx;
position: sticky;
top: 0;
z-index: 20;
background: rgba(252, 252, 252, 0.95);
backdrop-filter: blur(10px);
border-bottom: 1rpx solid rgba(0, 0, 0, 0.04);
margin-bottom: 20rpx;
}
.list-title {
font-size: 32rpx;
font-weight: 700;
color: #333;
}
.list-count {
margin-left: 12rpx;
font-size: 22rpx;
color: #999;
background: #F5F5F5;
padding: 4rpx 16rpx;
border-radius: 999rpx;
font-weight: 500;
}
/* 列表项 */
.list-item {
display: flex;
align-items: center;
padding: 24rpx;
border-radius: 32rpx;
border: 1rpx solid #F5F5F5;
background: #FFF;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.02);
margin-bottom: 16rpx;
transition: all 0.2s;
}
.list-item:active {
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
}
.list-item.playing {
background: rgba(255, 157, 66, 0.06);
border-color: rgba(255, 157, 66, 0.15);
}
.list-item.locked {
opacity: 0.45;
filter: grayscale(0.3);
}
.item-index {
width: 72rpx;
text-align: center;
font-size: 36rpx;
font-weight: 800;
color: #DDD;
font-style: italic;
margin-right: 12rpx;
flex-shrink: 0;
}
.item-info {
flex: 1;
padding-right: 16rpx;
overflow: hidden;
}
.item-title {
display: block;
font-size: 28rpx;
font-weight: 700;
color: #333;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.item-title.text-primary {
color: var(--color-primary);
}
.item-meta {
display: flex;
align-items: center;
margin-top: 8rpx;
font-size: 22rpx;
color: #999;
font-weight: 500;
}
.meta-dot {
margin: 0 8rpx;
}
.item-play-btn {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: #F5F5F5;
border: 1rpx solid #EEE;
transition: all 0.2s;
}
.item-play-btn.active {
background: var(--color-primary);
border-color: transparent;
box-shadow: 0 4rpx 16rpx rgba(255, 157, 66, 0.3);
}
.item-play-icon {
width: 28rpx;
height: 28rpx;
}
.item-play-btn.active .item-play-icon {
filter: brightness(0) invert(1);
}
+172
View File
@@ -0,0 +1,172 @@
/**
* 频道广场 — 从后端获取分类 + 频道列表
*
* 性能优化:直接使用 channel list 返回的 hasSubscribed 字段,
* 无需额外请求订阅列表,减少一次 HTTP 请求
*/
const app = getApp()
const api = require('../../utils/api')
Page({
data: {
isVip: false,
categories: [],
activeFilter: '',
filteredDomains: [],
loading: true
},
onLoad() {
this._loadCategories()
},
onShow() {
this._loadChannels()
this._onSubChange = () => this._loadChannels()
this._onVipChange = () => this._loadChannels()
app.on('subscriptionChange', this._onSubChange)
app.on('vipChange', this._onVipChange)
},
onHide() {
if (this._onSubChange) app.off('subscriptionChange', this._onSubChange)
if (this._onVipChange) app.off('vipChange', this._onVipChange)
},
/**
* 加载分类列表
*/
_loadCategories() {
const self = this
api.getCategoryList().then(function (res) {
if (res.code === 200 && res.data) {
const list = Array.isArray(res.data) ? res.data : (res.data.list || [])
const categories = [{ id: '', name: '全部' }].concat(list)
self.setData({ categories: categories })
}
}).catch(function (err) {
console.error('[Discover] 加载分类失败:', err)
})
},
/**
* 加载频道列表
* 直接使用后端返回的 hasSubscribed 字段,无需单独请求订阅列表
*/
_loadChannels() {
const self = this
const gd = app.globalData
const isFirstLoad = self.data.filteredDomains.length === 0
if (isFirstLoad) self.setData({ loading: true })
api.getChannelList({ categoryId: self.data.activeFilter, current: 1, pageSize: 50 })
.then(function (channelRes) {
var channels = []
if (channelRes.code === 200 && channelRes.data) {
channels = channelRes.data.list || channelRes.data || []
}
var filtered = channels.map(function (ch) {
var isFree = ch.isFree === 1
var isVipOnly = ch.isVipOnly === 1
// 最低价(分→元)
var lowestPrice = null
if (!isFree && !isVipOnly) {
var prices = [
{ label: '包月', value: ch.monthlyPrice },
{ label: '包季', value: ch.quarterlyPrice },
{ label: '包年', value: ch.annualPrice }
].filter(function (p) { return p.value > 0 })
if (prices.length > 0) {
prices.sort(function (a, b) { return a.value - b.value })
lowestPrice = { label: prices[0].label, value: (prices[0].value / 100).toFixed(2) }
}
}
return Object.assign({}, ch, {
_isSubscribed: ch.hasSubscribed === 1,
_isFree: isFree,
_isVipOnly: isVipOnly,
_lowestPrice: lowestPrice
})
})
self.setData({
isVip: gd.isVip,
filteredDomains: filtered,
loading: false
})
})
.catch(function (err) {
console.error('[Discover] 加载频道失败:', err)
self.setData({ loading: false })
})
},
/**
* 切换分类筛选
*/
onFilter(e) {
this.setData({ activeFilter: e.currentTarget.dataset.cat })
this._loadChannels()
},
/**
* 按鈕操作
* 1. 已订阅 → 跳转频道详情
* 2. isFree → 直接跳转频道详情(收听)
* 3. isVipOnly → 引导去 VIP 页
* 4. 付费订阅 → 跳转支付页
*/
onAction(e) {
const id = e.currentTarget.dataset.id
var channel = null
for (var i = 0; i < this.data.filteredDomains.length; i++) {
if (this.data.filteredDomains[i].id === id) {
channel = this.data.filteredDomains[i]
break
}
}
if (!channel) return
// 已订阅 → 直接进详情页
if (channel._isSubscribed) {
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 页
if (channel._isVipOnly) {
wx.navigateTo({ url: '/pages/vip/index' })
return
}
// 付费订阅 → 跳转 VIP/订阅页(channel 模式)
var params = 'channelId=' + id
+ '&channelName=' + encodeURIComponent(channel.name || '')
+ '&monthlyPrice=' + (channel.monthlyPrice || 0)
+ '&quarterlyPrice=' + (channel.quarterlyPrice || 0)
+ '&annualPrice=' + (channel.annualPrice || 0)
wx.navigateTo({ url: '/pages/vip/index?' + params })
},
/**
* 跳转频道详情
*/
goDetail(e) {
wx.navigateTo({ url: '/pages/channel-detail/index?id=' + e.currentTarget.dataset.id })
},
/**
* 跳转VIP
*/
goVip() {
wx.navigateTo({ url: '/pages/vip/index' })
}
})
+12
View File
@@ -0,0 +1,12 @@
{
"usingComponents": {
"global-player": "/components/global-player/index",
"t-message": "tdesign-miniprogram/message/message",
"t-image": "tdesign-miniprogram/image/image",
"t-tag": "tdesign-miniprogram/tag/tag",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-empty": "tdesign-miniprogram/empty/empty",
"t-loading": "tdesign-miniprogram/loading/loading"
},
"navigationBarTitleText": "频道广场"
}
+107
View File
@@ -0,0 +1,107 @@
<!-- 频道广场 —— 后端频道列表 + 分类筛选 -->
<page-meta page-style="overflow: hidden;" />
<view class="discover-page">
<!-- 分类筛选(横向滚动) -->
<scroll-view scroll-x enhanced show-scrollbar="{{false}}" class="filter-bar">
<view
wx:for="{{categories}}"
wx:key="id"
class="filter-tag {{activeFilter === item.id ? 'active' : ''}}"
bindtap="onFilter"
data-cat="{{item.id}}"
>
<text>{{item.name}}</text>
</view>
</scroll-view>
<!-- 频道网格(可滚动) -->
<scroll-view scroll-y enhanced show-scrollbar="{{false}}" class="grid-scroll">
<!-- 加载中(骨架屏) -->
<block wx:if="{{loading}}">
<view class="grid">
<view class="grid-item skeleton-item" wx:for="{{[1,2,3,4,5,6]}}" wx:key="*this">
<view class="skele-circle skele-bg"></view>
<view class="skele-line skele-bg"></view>
<view class="skele-btn skele-bg"></view>
</view>
</view>
</block>
<block wx:else>
<!-- 空状态 -->
<view wx:if="{{filteredDomains.length === 0}}" class="empty-wrap">
<t-empty description="暂无频道" />
</view>
<!-- 频道网格 -->
<view class="grid" wx:else>
<view
wx:for="{{filteredDomains}}"
wx:key="id"
class="grid-item"
>
<!-- 封面 emoji -->
<view class="item-icon" style="background: {{item.bgColor || '#FFE8CC'}};" bindtap="goDetail" data-id="{{item.id}}">
<text class="icon-emoji">{{item.cover || '📻'}}</text>
</view>
<!-- 名称 -->
<text class="item-name" bindtap="goDetail" data-id="{{item.id}}">{{item.name}}</text>
<!-- 类型标签 -->
<view class="item-tag-row">
<t-tag wx:if="{{item._isFree}}" size="small" variant="light" theme="success">免费</t-tag>
<t-tag wx:elif="{{item._isVipOnly}}" size="small" variant="light" theme="warning">VIP专享</t-tag>
<t-tag wx:elif="{{item._lowestPrice}}" size="small" variant="light" theme="default">¥{{item._lowestPrice.value}}/{{item._lowestPrice.label}}</t-tag>
</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>
</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专享 -->
<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>
</view>
</block>
<!-- 底部VIP横幅 -->
<view class="vip-banner tap-active" wx:if="{{!isVip}}" bindtap="goVip">
<view class="vip-banner-icon">
<text>👑</text>
</view>
<view class="vip-banner-info">
<text class="vip-banner-title">✨ 解锁全部频道 + 免广告</text>
<text class="vip-banner-desc">立享极致畅听体验</text>
</view>
<view class="vip-banner-price">
<text>19.9元/月</text>
</view>
</view>
<view style="height: 200rpx;"></view>
</scroll-view>
<!-- 全局播放条 -->
<global-player />
<t-message id="t-message" />
</view>
+209
View File
@@ -0,0 +1,209 @@
/* 频道广场样式 */
::-webkit-scrollbar { display: none !important; width: 0 !important; height: 0 !important; }
.discover-page {
display: flex;
flex-direction: column;
height: 100vh;
background: #F6F6F6;
overflow: hidden;
}
/* 分类筛选 */
.filter-bar {
flex-shrink: 0;
padding: 16rpx 32rpx;
white-space: nowrap;
background: #FFFFFF;
border-bottom: 1rpx solid #F5F5F5;
}
.filter-tag {
display: inline-block;
padding: 12rpx 28rpx;
border-radius: 999rpx;
font-size: 24rpx;
font-weight: 500;
margin-right: 12rpx;
background: #F5F5F5;
color: #666;
transition: all 0.2s;
}
.filter-tag.active {
background: #1A1A1A;
color: #FFF;
}
/* 网格滚动区 */
.grid-scroll {
flex: 1;
overflow: hidden;
}
.grid {
display: flex;
flex-wrap: wrap;
padding: 20rpx 20rpx 0;
gap: 16rpx;
}
.grid-item {
width: calc(33.33% - 12rpx);
box-sizing: border-box;
background: #FFFFFF;
border-radius: 28rpx;
display: flex;
flex-direction: column;
align-items: center;
padding: 24rpx 12rpx 20rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.04);
}
.item-icon {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 14rpx;
overflow: hidden;
}
.icon-emoji { font-size: 44rpx; }
.item-name {
font-size: 24rpx;
font-weight: 700;
color: #333;
text-align: center;
line-height: 1.3;
margin-bottom: 10rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
/* 类型标签行 */
.item-tag-row {
margin-bottom: 14rpx;
min-height: 36rpx;
display: flex;
align-items: center;
justify-content: center;
}
/* 行动按钮(通用容器) */
.sub-btn {
width: 100%;
height: 60rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
font-size: 22rpx;
font-weight: 700;
transition: opacity 0.2s, transform 0.15s;
}
.sub-btn:active { transform: scale(0.95); opacity: 0.85; }
/* 已订阅 */
.sub-btn.subscribed {
background: #F0F0F0;
color: #999;
}
/* 免费收听 */
.sub-btn.free {
background: linear-gradient(135deg, #2ECC71, #27AE60);
color: #FFF;
box-shadow: 0 4rpx 12rpx rgba(0, 200, 83, 0.25);
}
/* VIP专享 */
.sub-btn.vip {
background: linear-gradient(135deg, #F59E0B, #D97706);
color: #FFF;
box-shadow: 0 4rpx 12rpx rgba(245, 158, 11, 0.3);
}
/* 付费订阅 */
.sub-btn.paid {
background: linear-gradient(135deg, #FF9D42, #FF7832);
color: #FFF;
box-shadow: 0 4rpx 12rpx rgba(255, 157, 66, 0.3);
}
/* ========== 骨架屏 ========== */
@keyframes skele-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skele-bg {
background: linear-gradient(90deg, #F0F0F0 25%, #E8E8E8 37%, #F0F0F0 63%);
background-size: 400% 100%;
animation: skele-shimmer 1.4s ease infinite;
}
.skeleton-item {
background: #FFF;
}
.skele-circle {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
margin-bottom: 14rpx;
}
.skele-line {
width: 80%;
height: 28rpx;
border-radius: 6rpx;
margin-bottom: 24rpx;
}
.skele-btn {
width: 100%;
height: 60rpx;
border-radius: 999rpx;
}
/* 空状态 */
.empty-wrap { padding: 80rpx 0; }
/* VIP横幅 */
.vip-banner {
margin: 32rpx 20rpx;
padding: 28rpx;
background: linear-gradient(135deg, #1F2937, #111827);
border-radius: 32rpx;
display: flex;
align-items: center;
box-shadow: 0 12rpx 32rpx rgba(0, 0, 0, 0.15);
}
.vip-banner-icon {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: rgba(251, 191, 36, 0.2);
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
flex-shrink: 0;
font-size: 36rpx;
}
.vip-banner-info { flex: 1; overflow: hidden; }
.vip-banner-title {
display: block;
font-size: 26rpx;
font-weight: 700;
color: #FDE68A;
}
.vip-banner-desc {
display: block;
font-size: 20rpx;
color: #9CA3AF;
margin-top: 4rpx;
}
.vip-banner-price {
background: #FBBF24;
color: #1F2937;
padding: 12rpx 20rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 700;
flex-shrink: 0;
}
+116
View File
@@ -0,0 +1,116 @@
/**
* 收听历史 — 从后端获取历史列表
*/
const app = getApp()
const api = require('../../utils/api')
const util = require('../../utils/util')
Page({
data: {
filter: 'all',
cleared: false,
historyList: [],
isPlaying: false,
loading: true
},
onShow() {
this._refresh()
this._onPlayerChange = () => this._updatePlayState()
app.on('playerStateChange', this._onPlayerChange)
},
onHide() {
if (this._onPlayerChange) app.off('playerStateChange', this._onPlayerChange)
},
_refresh() {
if (this.data.cleared) return
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),
_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) {
return Object.assign({}, item, {
_isThisPlaying: gd.activeContent && gd.activeContent.id === item.id
})
})
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
for (var i = 0; i < this.data.historyList.length; i++) {
if (this.data.historyList[i].id === id) {
content = this.data.historyList[i]
break
}
}
if (!content) return
if (gd.activeContent && gd.activeContent.id === id) {
app.togglePlay()
} else {
app.playContent(content)
}
},
onClear() {
const self = this
wx.showModal({
title: '提示',
content: '确定要清空所有收听历史吗?',
success(res) {
if (res.confirm) {
self.setData({ cleared: true, historyList: [] })
}
}
})
}
})
+6
View File
@@ -0,0 +1,6 @@
{
"usingComponents": {
"global-player": "/components/global-player/index"
},
"navigationBarTitleText": "收听历史"
}
+71
View File
@@ -0,0 +1,71 @@
<!-- 收听历史 —— 按日期倒序的已听列表 -->
<view class="history-page">
<!-- 筛选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>
<view class="clear-btn tap-active" bindtap="onClear">
<text class="clear-icon">🗑</text>
<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>
<text class="empty-text">暂无收听历史</text>
</view>
<!-- 历史条目 -->
<view
wx:for="{{historyList}}"
wx:key="id"
class="history-item card"
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>
</view>
<!-- 信息 -->
<view class="h-info">
<text class="h-channel">{{item._domainName}}</text>
<text class="h-title {{item._isThisPlaying ? 'text-primary' : ''}}">{{item.title}}</text>
<text class="h-meta">{{item._friendlyDate}} · {{item.durationText}}</text>
</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>
<view wx:else class="play-mini">
<image src="/assets/icons/play.svg" class="play-mini-icon" />
</view>
</view>
</view>
<view style="height: 200rpx;"></view>
<global-player />
</view>
+177
View File
@@ -0,0 +1,177 @@
/* 收听历史样式 */
.history-page {
min-height: 100vh;
background: var(--color-bg-page);
}
/* 筛选头部 */
.filter-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 32rpx;
background: #FFFFFF;
border-bottom: 1rpx solid #F5F5F5;
}
.clear-btn {
display: flex;
align-items: center;
}
.clear-icon {
font-size: 22rpx;
margin-right: 6rpx;
}
.clear-text {
font-size: 22rpx;
color: #999;
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;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 160rpx 0;
opacity: 0.5;
}
.empty-icon-wrap {
width: 128rpx;
height: 128rpx;
border-radius: 50%;
background: #F5F5F5;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24rpx;
}
.empty-emoji {
font-size: 48rpx;
}
.empty-text {
font-size: 28rpx;
color: #999;
font-weight: 500;
}
/* 历史条目 */
.history-item {
display: flex;
align-items: center;
padding: 24rpx;
margin-bottom: 20rpx;
}
.history-item:active {
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.06);
}
.h-icon {
width: 88rpx;
height: 88rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.h-emoji {
font-size: 40rpx;
}
.h-info {
flex: 1;
padding: 0 20rpx;
overflow: hidden;
}
.h-channel {
display: block;
font-size: 20rpx;
font-weight: 700;
color: #999;
letter-spacing: 2rpx;
margin-bottom: 6rpx;
}
.h-title {
display: block;
font-size: 26rpx;
font-weight: 700;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.h-title.text-primary {
color: var(--color-primary);
}
.h-meta {
display: block;
font-size: 20rpx;
color: #BBB;
margin-top: 6rpx;
font-weight: 500;
}
/* 播放指示器 */
.playing-indicator {
display: flex;
align-items: flex-end;
gap: 4rpx;
width: 48rpx;
height: 48rpx;
justify-content: center;
flex-shrink: 0;
}
.bar {
width: 6rpx;
border-radius: 4rpx;
background: var(--color-primary);
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); }
}
.play-mini {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
border: 2rpx solid #EEE;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.play-mini-icon {
width: 20rpx;
height: 20rpx;
opacity: 0.4;
}
+321
View File
@@ -0,0 +1,321 @@
/**
* 首页 — 订阅频道 + 免费频道
*
* 数据来源:
* - 订阅列表: POST /radio/subscription/list
* - 免费频道: POST /radio/channel/freeList
*
* 订阅返回结构: { data: { list: [{ id, channel: { id, name, cover, Programs[] } }] } }
* 免费频道结构: { data: { list: [{ id, name, cover, Programs[] }] } } (待确认类似)
*/
const app = getApp()
const api = require('../../utils/api')
const util = require('../../utils/util')
Page({
data: {
greetingSub: '',
locationName: '',
weather: null,
dateDisplay: '',
weekDay: '',
subscribedData: [],
freeChannels: [],
isPlaying: false,
isVip: false,
statusBarHeight: 0,
loadingSub: true,
loadingFree: true
},
onShow() {
const gd = app.globalData
this.setData({
greetingSub: this._getGreeting(),
dateDisplay: util.getDateDisplay(),
weekDay: util.getWeekDay(),
locationName: gd.locationName || '',
weather: gd.weather || null,
isVip: gd.isVip || false,
statusBarHeight: gd.statusBarHeight || 0
})
this._loadAll()
this._bindEvents()
},
onHide() {
this._unbindEvents()
},
// ===================== 数据加载 =====================
/** 并行加载订阅列表 + 免费频道 */
_loadAll() {
this._refreshSubscriptions()
this._loadFreeChannels()
},
/**
* 拉取已订阅频道列表
* 首次无数据时显示骨架屏,后续刷新静默替换(无闪烁)
*/
_refreshSubscriptions() {
const self = this
const gd = app.globalData
const isFirstLoad = self.data.subscribedData.length === 0
// 只有首次加载才显示骨架屏
if (isFirstLoad) {
self.setData({ loadingSub: true })
}
api.getSubscriptionList({ current: 1, pageSize: 50 })
.then(function (res) {
if (res.code !== 200 || !res.data) {
self.setData({ subscribedData: [], loadingSub: false })
return
}
const subList = res.data.list || []
const subscribedData = subList.map(function (subItem) {
return self._mapChannel(subItem.channel || {}, gd)
})
self.setData({
subscribedData: subscribedData,
isPlaying: gd.isPlaying || false,
loadingSub: false
})
})
.catch(function (err) {
console.error('[首页] 订阅列表失败', err)
self.setData({ loadingSub: false })
})
},
/**
* 拉取免费频道列表
* 首次无数据时显示骨架屏,后续静默刷新
*/
_loadFreeChannels() {
const self = this
const isFirstLoad = self.data.freeChannels.length === 0
if (isFirstLoad) {
self.setData({ loadingFree: true })
}
api.getFreeChannelList({ current: 1, pageSize: 20 })
.then(function (res) {
if (res.code !== 200 || !res.data) {
self.setData({ freeChannels: [], loadingFree: false })
return
}
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
}
})
self.setData({ freeChannels: freeChannels, loadingFree: false })
})
.catch(function (err) {
console.error('[首页] 免费频道失败', err)
self.setData({ loadingFree: false })
})
},
/**
* 将频道对象映射为 UI 数据结构(订阅区通用)
*/
_mapChannel(channel, gd) {
const programs = channel.programs || []
const latest = programs.length > 0 ? programs[0] : null
// cover 直接是 emoji 字符串
const cover = channel.cover || '📻'
const todayContent = latest ? Object.assign({}, latest, {
durationText: util.formatTime(latest.duration || 0)
}) : null
const isThisPlaying = !!(todayContent &&
gd.activeContent &&
gd.activeContent.id === todayContent.id)
return {
id: channel.id,
name: channel.name || '未命名频道',
description: channel.description || '',
isFree: channel.isFree,
isVipOnly: channel.isVipOnly,
cover: cover,
bgColor: this._genColor(channel.id),
_todayContent: todayContent,
_isThisPlaying: isThisPlaying
}
},
/**
* 根据 id 生成稳定的暖色系颜色
*/
_genColor(id) {
const palette = [
'#FF9D42', '#FFB366', '#FF8C69', '#FFA07A',
'#E8956D', '#D4845A', '#F4A460', '#CD853F'
]
if (!id) return palette[0]
var hash = 0
for (var i = 0; i < id.length; i++) {
hash = (hash + id.charCodeAt(i)) % palette.length
}
return palette[hash]
},
// ===================== 事件监听 =====================
_bindEvents() {
const self = this
// 播放状态变化
this._onPlayerChange = function () {
const gd = app.globalData
const data = self.data.subscribedData.map(function (channel) {
const latest = channel._todayContent
return Object.assign({}, channel, {
_isThisPlaying: !!(latest &&
gd.activeContent &&
gd.activeContent.id === latest.id)
})
})
self.setData({ subscribedData: data, isPlaying: gd.isPlaying || false })
}
// 订阅变化
this._onSubChange = function () {
self._loadAll()
}
// 冷启动位置/天气数据就绪(异步,可能比页面加载晚)
this._onLocationWeather = function (data) {
self.setData({
locationName: data.locationName || '',
weather: data.weather || null
})
}
app.on('playerStateChange', this._onPlayerChange)
app.on('subscriptionChange', this._onSubChange)
app.on('locationWeatherReady', this._onLocationWeather)
},
_unbindEvents() {
if (this._onPlayerChange) app.off('playerStateChange', this._onPlayerChange)
if (this._onSubChange) app.off('subscriptionChange', this._onSubChange)
if (this._onLocationWeather) app.off('locationWeatherReady', this._onLocationWeather)
},
// ===================== 用户操作 =====================
/** 点击播放/暂停 */
onPlayContent(e) {
const contentId = e.currentTarget.dataset.contentId
const gd = app.globalData
var content = null
for (var i = 0; i < this.data.subscribedData.length; i++) {
var c = this.data.subscribedData[i]._todayContent
if (c && c.id === contentId) { content = c; break }
}
if (!content) return
if (gd.activeContent && gd.activeContent.id === contentId) {
app.togglePlay()
} else {
app.playContent(content)
}
},
/** 跳转发现广场 */
goDiscover() {
wx.switchTab({ url: '/pages/discover/index' })
},
/** 跳转 VIP 页 */
goVip() {
wx.navigateTo({ url: '/pages/vip/index' })
},
/** 跳转频道详情(订阅或免费) */
goChannel(e) {
const id = e.currentTarget.dataset.id
wx.navigateTo({ url: '/pages/channel-detail/index?id=' + id })
},
// ===================== 工具方法 =====================
/** 根据时间段返回问候语 */
_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)]
}
})
+15
View File
@@ -0,0 +1,15 @@
{
"usingComponents": {
"global-player": "/components/global-player/index",
"t-message": "tdesign-miniprogram/message/message",
"t-image": "tdesign-miniprogram/image/image",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-tag": "tdesign-miniprogram/tag/tag",
"t-empty": "tdesign-miniprogram/empty/empty",
"t-divider": "tdesign-miniprogram/divider/divider",
"t-loading": "tdesign-miniprogram/loading/loading",
"t-cell": "tdesign-miniprogram/cell/cell",
"t-badge": "tdesign-miniprogram/badge/badge"
},
"navigationStyle": "custom"
}
+214
View File
@@ -0,0 +1,214 @@
<!-- 禁用页面原生滚动 -->
<page-meta page-style="overflow: hidden;" />
<!-- 整体容器:flex 纵向布局 -->
<view class="index-page">
<!-- 状态栏占位 -->
<view style="height: {{statusBarHeight}}px; flex-shrink: 0; background: #FCFCFC;"></view>
<!-- ===== 自定义导航栏 ===== -->
<view class="custom-nav">
<text class="nav-brand-name">全声汇</text>
</view>
<!-- ===== 顶部问候栏(吸顶) ===== -->
<view class="meta-bar">
<!-- 时段文案(根据时间变化) -->
<text class="greeting-sub">{{greetingSub}}</text>
<!-- 信息行 -->
<view class="info-row">
<view class="info-item" wx:if="{{locationName}}">
<t-icon name="location" size="26rpx" color="#FF9D42" />
<text class="info-text loc-name-text">{{locationName}}</text>
</view>
<text class="info-dot" wx:if="{{locationName}}">·</text>
<text class="info-text">{{dateDisplay}} {{weekDay}}</text>
<block wx:if="{{weather}}">
<text class="info-dot">·</text>
<text class="weather-icon-sm">{{weather.icon}}</text>
<text class="info-text">{{weather.desc}} {{weather.temp}}°C</text>
</block>
</view>
</view>
<!-- ===== 可滚动内容区 ===== -->
<scroll-view
scroll-y
enhanced
show-scrollbar="{{false}}"
class="main-scroll"
>
<view class="content-area">
<!-- ─── Section 1: 我的订阅 ─── -->
<view class="section-header">
<view class="section-title-wrap">
<text class="section-dot"></text>
<text class="section-title">我的订阅</text>
</view>
<view class="section-action tap-active" bindtap="goDiscover">
<text class="section-action-text">管理</text>
<t-icon name="chevron-right" size="32rpx" color="#CCC" />
</view>
</view>
<!-- 订阅加载中 (骨架屏) -->
<block wx:if="{{loadingSub}}">
<view class="skeleton-sub-card card" wx:for="{{[1,2]}}" wx:key="*this">
<view class="skele-header">
<view class="skele-avatar skele-bg"></view>
<view class="skele-text-group">
<view class="skele-title skele-bg"></view>
<view class="skele-subtitle skele-bg"></view>
</view>
</view>
<view class="skele-play-row skele-bg"></view>
</view>
</block>
<!-- 订阅空状态 -->
<view wx:elif="{{subscribedData.length === 0}}" class="sub-empty-card" bindtap="goDiscover">
<view class="sub-empty-icon">
<text class="sub-empty-emoji">🎤</text>
</view>
<view class="sub-empty-body">
<text class="sub-empty-title">还没有订阅</text>
<text class="sub-empty-desc">去发现感兴趣的频道,每天为你准时送达</text>
</view>
<view class="sub-empty-arrow">
<t-icon name="chevron-right" size="36rpx" color="#CCC" />
</view>
</view>
<!-- 订阅列表 -->
<block wx:else>
<view
wx:for="{{subscribedData}}"
wx:key="id"
class="channel-card card"
>
<!-- 左侧色条 -->
<view class="card-accent" style="background: {{item.bgColor}};"></view>
<!-- 频道头部 -->
<view class="card-header" bindtap="goChannel" data-id="{{item.id}}">
<view class="channel-icon" style="background: {{item.bgColor || '#FFE8CC'}};">
<text class="icon-emoji">{{item.cover || '📻'}}</text>
</view>
<view class="channel-info">
<text class="channel-name">{{item.name}}</text>
<view class="channel-meta">
<t-tag wx:if="{{item.isFree === 1}}" size="small" variant="light" theme="success">免费</t-tag>
<t-tag wx:elif="{{item.isVipOnly === 1}}" size="small" variant="light" theme="warning">👑 VIP</t-tag>
<t-tag wx:if="{{item._todayContent}}" size="small" variant="light" theme="primary">新内容</t-tag>
</view>
</view>
<t-icon name="chevron-right" size="40rpx" color="#CCC" />
</view>
<!-- 最新节目播放行 -->
<view
wx:if="{{item._todayContent}}"
class="play-row {{item._isThisPlaying ? 'playing' : ''}}"
bindtap="onPlayContent"
data-content-id="{{item._todayContent.id}}"
>
<!-- 左:音波动画 or 音乐图标 -->
<view class="play-left">
<view wx:if="{{item._isThisPlaying && isPlaying}}" class="sound-wave">
<view class="wave-bar wave-1"></view>
<view class="wave-bar wave-2"></view>
<view class="wave-bar wave-3"></view>
<view class="wave-bar wave-4"></view>
</view>
<t-icon wx:else name="sound" size="36rpx" color="{{item._isThisPlaying ? '#FF9D42' : '#BBB'}}" />
</view>
<!-- 中:标题 + 时长 -->
<view class="play-info">
<text class="play-title {{item._isThisPlaying ? 'text-primary' : ''}}">{{item._todayContent.title}}</text>
<text class="play-duration">{{item._todayContent.durationText}}</text>
</view>
<!-- 右:播放按钮 -->
<view class="play-btn-wrap">
<view class="play-btn-ring {{item._isThisPlaying ? 'active' : ''}}"></view>
<view class="play-btn {{item._isThisPlaying ? 'active' : ''}}">
<t-icon wx:if="{{item._isThisPlaying && isPlaying}}" name="pause" size="32rpx" color="#FFF" />
<t-icon wx:else name="play" size="32rpx" color="{{item._isThisPlaying ? '#FFF' : '#555'}}" />
</view>
</view>
</view>
<!-- 该频道暂无节目 -->
<view wx:else class="no-content">
<text class="no-content-text">暂无节目,敬请期待</text>
</view>
</view>
</block>
<!-- ─── Section 2: 免费频道 ─── -->
<t-divider style="margin: 12rpx 0;" />
<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>
</view>
<view class="section-action tap-active" bindtap="goDiscover">
<text class="section-action-text">全部</text>
<t-icon name="chevron-right" size="32rpx" color="#CCC" />
</view>
</view>
<!-- 免费频道加载中 (骨架屏) -->
<block wx:if="{{loadingFree}}">
<view class="free-list" style="padding: 4rpx 32rpx 16rpx;">
<view class="skeleton-free-card" wx:for="{{[1,2,3,4]}}" wx:key="*this">
<view class="skele-free-avatar skele-bg"></view>
<view class="skele-free-text skele-bg"></view>
</view>
</view>
</block>
<!-- 免费频道横向滚动列表 -->
<block wx:if="{{!loadingFree && freeChannels.length > 0}}">
<scroll-view
scroll-x
enhanced
show-scrollbar="{{false}}"
class="free-scroll"
>
<view class="free-list">
<view
wx:for="{{freeChannels}}"
wx:key="id"
class="free-card tap-active"
bindtap="goChannel"
data-id="{{item.id}}"
>
<view class="free-icon" style="background: {{item.bgColor || '#FFE8CC'}};">
<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>
</view>
</view>
</scroll-view>
</block>
<!-- 免费区无数据 -->
<view wx:if="{{!loadingFree && freeChannels.length === 0}}" class="free-empty">
<t-empty description="暂无免费频道" />
</view>
<!-- 底部占位 -->
<view style="height: 200rpx;"></view>
</view>
</scroll-view>
<!-- 全局播放条 -->
<global-player />
<t-message id="t-message" />
</view>
+496
View File
@@ -0,0 +1,496 @@
/* ============================================
首页样式 — 订阅频道 + 免费频道
============================================ */
/* 强制隐藏本页所有滚动条 */
::-webkit-scrollbar {
display: none !important;
width: 0 !important;
height: 0 !important;
}
/* 整体容器:flex 纵向,meta-bar 固定 + scroll 填充 */
.index-page {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--color-bg-page);
overflow: hidden;
}
/* ========== 自定义导航栏 ========== */
.custom-nav {
display: flex;
align-items: center;
justify-content: center;
height: 80rpx;
background: #FFFAF5;
flex-shrink: 0;
position: relative;
}
.nav-brand-name {
font-size: 34rpx;
font-weight: 600;
font-family: 'PingFang SC', 'Helvetica Neue', sans-serif;
color: #2C1A08;
letter-spacing: 4rpx;
}
/* ========== 顶部问候栏(吸顶) ========== */
.meta-bar {
flex-shrink: 0;
background: linear-gradient(180deg, #FFFAF5 0%, #FFFFFF 100%);
padding: 28rpx 36rpx 22rpx;
border-bottom: 1rpx solid #F0ECE8;
z-index: 10;
}
/* 问候语行:emoji + 文字 */
.greeting-row {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 16rpx;
}
.greeting-emoji {
font-size: 52rpx;
line-height: 1;
}
.greeting-text-wrap {
display: flex;
flex-direction: column;
}
.greeting {
font-size: 48rpx;
font-weight: 800;
color: #2A2A2A;
letter-spacing: 2rpx;
line-height: 1.15;
font-family: 'PingFang SC', -apple-system, BlinkMacSystemFont, sans-serif;
}
.greeting-sub {
display: block;
font-size: 30rpx;
font-weight: 600;
color: #5C3D1E;
letter-spacing: 1rpx;
margin-bottom: 12rpx;
font-family: 'PingFang SC', -apple-system, sans-serif;
}
/* 信息行:位置 · 日期 · 天气 */
.info-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8rpx;
}
.info-item {
display: flex;
align-items: center;
gap: 6rpx;
background: rgba(255, 157, 66, 0.08);
padding: 6rpx 16rpx;
border-radius: 999rpx;
}
.loc-name-text {
font-weight: 600;
color: #8B6914;
}
.info-text {
font-size: 22rpx;
color: #999;
font-weight: 500;
}
.info-dot {
font-size: 22rpx;
color: #D5D0CA;
margin: 0 2rpx;
}
.weather-icon-sm { font-size: 22rpx; }
/* ========== 可滚动内容区 ========== */
.main-scroll {
flex: 1;
overflow: hidden;
}
/* ========== 主内容区 ========== */
.content-area {
padding: 24rpx 32rpx 0;
}
/* ========== Section Header ========== */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.section-header-free {
margin-top: 40rpx;
}
.section-title-wrap {
display: flex;
align-items: center;
gap: 12rpx;
}
.section-dot {
width: 8rpx;
height: 28rpx;
border-radius: 999rpx;
background: var(--color-primary);
display: block;
flex-shrink: 0;
}
.dot-free {
background: #00C853;
}
.section-title {
font-size: 32rpx;
font-weight: 800;
color: #1A1A1A;
letter-spacing: 1rpx;
}
.section-subtitle {
font-size: 22rpx;
color: #AAAAAA;
font-weight: 400;
}
.section-action {
display: flex;
align-items: center;
gap: 4rpx;
}
.section-action-text {
font-size: 24rpx;
color: #999;
font-weight: 500;
}
.section-action-arrow {
font-size: 32rpx;
color: #CCC;
line-height: 1;
}
/* ========== 骨架屏动画 ========== */
@keyframes skele-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skele-bg {
background: linear-gradient(90deg, #F0F0F0 25%, #E8E8E8 37%, #F0F0F0 63%);
background-size: 400% 100%;
animation: skele-shimmer 1.4s ease infinite;
}
/* 订阅卡片骨架 */
.skeleton-sub-card {
margin-bottom: 20rpx;
padding: 28rpx;
border: none;
box-shadow: none;
}
.skele-header {
display: flex;
align-items: center;
margin-bottom: 24rpx;
}
.skele-avatar {
width: 76rpx;
height: 76rpx;
border-radius: 22rpx;
margin-right: 18rpx;
}
.skele-text-group { flex: 1; }
.skele-title {
width: 40%;
height: 32rpx;
border-radius: 6rpx;
margin-bottom: 12rpx;
}
.skele-subtitle {
width: 25%;
height: 24rpx;
border-radius: 6rpx;
}
.skele-play-row {
width: 100%;
height: 100rpx;
border-radius: 20rpx;
}
/* ========== 订阅空状态(横向卡片样式) ========== */
.sub-empty-card {
display: flex;
align-items: center;
gap: 20rpx;
background: #FFFFFF;
border-radius: 28rpx;
padding: 32rpx 28rpx;
margin-bottom: 20rpx;
border: 2rpx dashed #EDE8E2;
box-shadow: none;
}
.sub-empty-card:active { opacity: 0.75; }
.sub-empty-icon {
width: 80rpx;
height: 80rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, rgba(255, 157, 66, 0.12), rgba(255, 120, 50, 0.2));
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.sub-empty-emoji { font-size: 38rpx; }
.sub-empty-body { flex: 1; min-width: 0; }
.sub-empty-title {
display: block;
font-size: 28rpx;
font-weight: 700;
color: #333;
margin-bottom: 8rpx;
}
.sub-empty-desc {
display: block;
font-size: 22rpx;
color: #AAA;
line-height: 1.5;
}
.sub-empty-arrow { flex-shrink: 0; }
/* ========== 订阅频道卡片 ========== */
.channel-card {
margin-bottom: 20rpx;
padding: 28rpx 28rpx 24rpx;
position: relative;
overflow: hidden;
}
.card-accent {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 8rpx;
border-radius: 0 6rpx 6rpx 0;
}
/* 卡片头部 */
.card-header {
display: flex;
align-items: center;
padding-left: 12rpx;
margin-bottom: 16rpx;
}
.channel-icon {
width: 76rpx;
height: 76rpx;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 18rpx;
flex-shrink: 0;
overflow: hidden;
}
.icon-emoji { font-size: 36rpx; }
.channel-info { flex: 1; min-width: 0; }
.channel-name {
display: block;
font-size: 30rpx;
font-weight: 700;
color: #222;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.channel-meta {
display: flex;
align-items: center;
gap: 8rpx;
margin-top: 8rpx;
}
/* t-tag 和 t-icon 已替代旧 .tag / .card-arrow */
/* 播放行 */
.play-row {
display: flex;
align-items: center;
padding: 20rpx 18rpx 20rpx 16rpx;
border-radius: 24rpx;
background: #F7F7F7;
transition: all 0.3s ease;
gap: 16rpx;
}
.play-row:active { background: #EEEEEE; }
.play-row.playing {
background: linear-gradient(135deg, rgba(255, 157, 66, 0.08) 0%, rgba(255, 120, 50, 0.15) 100%);
border: 1rpx solid rgba(255, 157, 66, 0.15);
}
/* 左侧:音波 or 图标 */
.play-left {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* 音波动画 */
.sound-wave {
display: flex;
align-items: flex-end;
gap: 5rpx;
height: 36rpx;
}
.wave-bar {
width: 6rpx;
border-radius: 6rpx;
background: var(--color-primary);
animation: wave-bounce 0.8s ease-in-out infinite;
}
.wave-1 { height: 14rpx; animation-delay: 0s; }
.wave-2 { height: 26rpx; animation-delay: 0.15s; }
.wave-3 { height: 20rpx; animation-delay: 0.3s; }
.wave-4 { height: 30rpx; animation-delay: 0.45s; }
@keyframes wave-bounce {
0%, 100% { transform: scaleY(0.4); opacity: 0.6; }
50% { transform: scaleY(1); opacity: 1; }
}
/* 中间信息 */
.play-info { flex: 1; padding-right: 12rpx; overflow: hidden; }
.play-title {
display: block;
font-size: 27rpx;
font-weight: 700;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.play-title.text-primary { color: var(--color-primary); }
.play-duration { display: block; font-size: 21rpx; color: #AAA; margin-top: 6rpx; }
/* 右侧:播放按钮 + 光环 */
.play-btn-wrap {
position: relative;
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* 外圈光环 */
.play-btn-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 3rpx solid transparent;
transition: all 0.3s;
}
.play-btn-ring.active {
border-color: rgba(255, 157, 66, 0.25);
animation: ring-pulse 2s ease-in-out infinite;
}
@keyframes ring-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.15); opacity: 0.4; }
}
/* 按钮主体 */
.play-btn {
width: 68rpx;
height: 68rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: #ECECEC;
flex-shrink: 0;
transition: all 0.25s ease;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
}
.play-btn:active { transform: scale(0.88); }
.play-btn.active {
background: linear-gradient(135deg, #FF9D42 0%, #FF7832 100%);
box-shadow: 0 8rpx 24rpx rgba(255, 120, 50, 0.4);
}
/* t-icon 已替代旧 .play-icon / .pause-bars */
/* 暂无节目 */
.no-content { padding: 16rpx 12rpx; }
.no-content-text { font-size: 23rpx; color: #CCCCCC; }
/* ========== 免费频道横向区 ========== */
.skeleton-free-card {
display: inline-flex;
flex-direction: column;
align-items: center;
width: 160rpx;
vertical-align: top;
margin-right: 20rpx;
}
.skele-free-avatar {
width: 120rpx;
height: 120rpx;
border-radius: 36rpx;
margin-bottom: 12rpx;
}
.skele-free-text {
width: 100rpx;
height: 26rpx;
border-radius: 6rpx;
}
.free-scroll {
width: 100%;
overflow: hidden;
}
.free-list {
padding: 4rpx 4rpx 16rpx;
white-space: nowrap;
}
.free-card {
display: inline-flex;
flex-direction: column;
align-items: center;
width: 160rpx;
vertical-align: top;
margin-right: 20rpx;
}
.free-icon {
width: 120rpx;
height: 120rpx;
border-radius: 32rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12rpx;
overflow: hidden;
box-shadow: 0 6rpx 16rpx rgba(0, 0, 0, 0.08);
}
.free-cover { width: 100%; height: 100%; }
.free-emoji { font-size: 48rpx; }
.free-name {
font-size: 22rpx;
font-weight: 700;
color: #333;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 150rpx;
margin-bottom: 8rpx;
line-height: 1.3;
}
/* t-tag 已替代 .free-badget-empty 已替代 .free-empty */
.free-empty {
padding: 40rpx 0 20rpx;
}
+64
View File
@@ -0,0 +1,64 @@
/**
* 首次引导页 — 选择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
@@ -0,0 +1,6 @@
{
"usingComponents": {
"t-message": "tdesign-miniprogram/message/message"
},
"navigationStyle": "custom"
}
+50
View File
@@ -0,0 +1,50 @@
<!-- 首次引导页 —— 选择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
@@ -0,0 +1,170 @@
/* 首次引导页样式 */
.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;
}
+268
View File
@@ -0,0 +1,268 @@
/**
* 播放器详情页
* 大封面、进度条、倍速切换、播放控制
* 从 globalData.activeContent 获取当前节目
*/
const app = getApp()
const api = require('../../utils/api')
const util = require('../../utils/util')
Page({
data: {
domain: {},
activeContent: null,
isPlaying: false,
isVip: false,
currentTime: 0,
duration: 0,
currentTimeText: '00:00',
durationText: '00:00',
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' }
]
},
_isSeeking: false,
onLoad() {
},
onShow() {
this._syncState()
// 监听播放状态
this._onPlayerChange = (state) => {
if (this._isSeeking) return
this.setData({
activeContent: state.activeContent,
isPlaying: state.isPlaying,
playbackRate: state.playbackRate
})
this._updateDomain()
}
// 监听时间更新
this._onTimeUpdate = (data) => {
if (this._isSeeking) return
this.setData({
currentTime: data.currentTime,
duration: data.duration || this.data.duration,
currentTimeText: util.formatTime(data.currentTime),
durationText: util.formatTime(data.duration || this.data.duration)
})
}
app.on('playerStateChange', this._onPlayerChange)
app.on('timeUpdate', this._onTimeUpdate)
},
onHide() {
if (this._onPlayerChange) app.off('playerStateChange', this._onPlayerChange)
if (this._onTimeUpdate) app.off('timeUpdate', this._onTimeUpdate)
},
/**
* 同步当前状态
*/
_syncState() {
const gd = app.globalData
const content = gd.activeContent
if (!content) {
wx.navigateBack()
return
}
var dateStr = ''
if (content.createdAt) {
dateStr = content.createdAt.substring(0, 10).replace(/-/g, '.')
} else if (content.date) {
dateStr = util.dateToDot(content.date)
}
this.setData({
activeContent: content,
isPlaying: gd.isPlaying,
isVip: gd.isVip,
currentTime: gd.currentTime,
duration: gd.duration || content.duration,
currentTimeText: util.formatTime(gd.currentTime),
durationText: util.formatTime(gd.duration || content.duration),
displayDate: dateStr,
playbackRate: gd.playbackRate,
statusBarHeight: gd.statusBarHeight || 0
})
this._updateDomain()
},
/**
* 获取频道信息 — 从后端 API 获取
*/
_updateDomain() {
const content = this.data.activeContent
if (!content) return
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
api.getChannelDetail(channelId).then(function (res) {
if (res.code === 200 && res.data) {
self.setData({ domain: res.data })
}
}).catch(function (err) {
console.error('[Player] 获取频道信息失败:', err)
})
},
/**
* 播放/暂停
*/
onTogglePlay() {
app.togglePlay()
},
/**
* 进度条拖动中
*/
onSliderChanging(e) {
this._isSeeking = true
this.setData({
currentTime: e.detail.value,
currentTimeText: util.formatTime(e.detail.value)
})
},
/**
* 进度条拖动完成 → 跳转播放
*/
onSliderChange(e) {
this._isSeeking = false
app.seekTo(e.detail.value)
},
/**
* 快退 15 秒
*/
onBackward() {
const newTime = Math.max(0, this.data.currentTime - 15)
app.seekTo(newTime)
},
/**
* 快进 15 秒
*/
onForward() {
const newTime = Math.min(this.data.duration, this.data.currentTime + 15)
app.seekTo(newTime)
},
/**
* 倍速设置(VIP 功能)
*/
onSpeed() {
if (!this.data.isVip) {
wx.showModal({
title: '会员提示',
content: '倍速播放是会员专属功能,是否前往开通?',
success: (res) => {
if (res.confirm) {
wx.navigateTo({ url: '/pages/vip/index' })
}
}
})
} else {
this.setData({ showSpeedSheet: true })
}
},
onSpeedSelect(e) {
const label = this.data.speedItems[e.detail.index].label
const rate = parseFloat(label)
app.setPlaybackRate(rate)
this.setData({ showSpeedSheet: false, playbackRate: rate })
},
onSpeedCancel() {
this.setData({ showSpeedSheet: false })
},
/**
* 下载(VIP 功能)
*/
onDownload() {
if (!this.data.isVip) {
wx.showModal({
title: '会员提示',
content: '音频下载是会员专属功能,是否前往开通?',
success: (res) => {
if (res.confirm) {
wx.navigateTo({ url: '/pages/vip/index' })
}
}
})
} else {
wx.showToast({ title: '下载功能开发中', icon: 'none' })
}
},
/**
* 查看文案
*/
onTranscript() {
const content = this.data.activeContent
if (content && content.content) {
wx.showModal({
title: '完整文案',
content: content.content,
showCancel: false
})
}
},
/**
* 点击中央 Banner:封面 ⇔ 文案切换
*/
onBannerTap() {
this.setData({ showTranscript: !this.data.showTranscript })
},
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' })
}).catch(function () {
wx.showToast({ title: '网络异常', icon: 'none' })
})
},
onShare() {
wx.showToast({ title: '分享功能开发中', icon: 'none' })
},
goBack() {
wx.navigateBack()
}
})
+7
View File
@@ -0,0 +1,7 @@
{
"usingComponents": {
"t-action-sheet": "tdesign-miniprogram/action-sheet/action-sheet"
},
"navigationStyle": "custom",
"navigationBarTitleText": "正在播放"
}
+117
View File
@@ -0,0 +1,117 @@
<!-- 播放器详情页 -->
<page-meta page-style="overflow:hidden; background:#1A1208;" />
<view class="player-page">
<!-- 状态栏占位(custom 导航模式必须手动留出状态栏高度) -->
<view style="height: {{statusBarHeight}}px; flex-shrink: 0;"></view>
<!-- 环境光背景 -->
<view class="bg-glow"
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>
</view>
<view class="top-title-wrap">
<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>
</view>
<!-- 中央 Banner:封面 ↔ 文案 -->
<view class="banner-area" bindtap="onBannerTap">
<!-- 封面视图(默认) -->
<view class="banner-cover {{showTranscript ? 'banner-hidden' : ''}}">
<!-- 涟漪声波(播放中才显示) -->
<view class="ripple-wrap" wx:if="{{isPlaying}}">
<view class="ripple-ring" style="animation-delay: 0s;"></view>
<view class="ripple-ring" style="animation-delay: 0.7s;"></view>
<view class="ripple-ring" style="animation-delay: 1.4s;"></view>
</view>
<!-- 大 emoji,无卡片背景 -->
<text class="cover-emoji {{isPlaying ? 'emoji-active' : ''}}">📻</text>
<!-- 频道名 -->
<text class="cover-channel">{{domain.name}}</text>
<text class="banner-hint">点击查看文案</text>
</view>
<!-- 文案视图 -->
<view class="banner-transcript {{showTranscript ? '' : 'banner-hidden'}}">
<scroll-view scroll-y enhanced show-scrollbar="{{false}}" class="transcript-scroll">
<text class="transcript-content" wx:if="{{activeContent.content}}">{{activeContent.content}}</text>
<view wx:else class="transcript-empty">
<text class="transcript-empty-icon">📄</text>
<text class="transcript-empty-text">暂无文案</text>
</view>
</scroll-view>
<text class="banner-hint">点击返回封面</text>
</view>
</view>
<!-- 日期 + 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>
</view>
</view>
<!-- 进度条 -->
<view class="progress-section">
<slider
class="progress-slider"
min="0"
max="{{duration}}"
value="{{currentTime}}"
activeColor="#FF9D42"
backgroundColor="rgba(255,255,255,0.12)"
block-size="14"
block-color="#FF9D42"
bindchange="onSliderChange"
bindchanging="onSliderChanging"
/>
<view class="time-row">
<text class="time-text">{{currentTimeText}}</text>
<text class="time-text">{{durationText}}</text>
</view>
</view>
<!-- 控制区:← 15s | 播放/暂停 | 15s → -->
<view class="controls-row">
<view class="skip-btn tap-active" bindtap="onBackward">
<text class="skip-arrow">↺</text>
<text class="skip-sec">15</text>
</view>
<view class="play-btn tap-active" bindtap="onTogglePlay">
<view wx:if="{{isPlaying}}" class="pause-group">
<view class="pause-bar"></view>
<view class="pause-bar"></view>
</view>
<text wx:else class="play-tri">▶</text>
</view>
<view class="skip-btn tap-active" bindtap="onForward">
<text class="skip-arrow">↻</text>
<text class="skip-sec">15</text>
</view>
</view>
<!-- 倍速选择面板 -->
<t-action-sheet
visible="{{showSpeedSheet}}"
items="{{speedItems}}"
bind:selected="onSpeedSelect"
bind:cancel="onSpeedCancel"
/>
</view>
+322
View File
@@ -0,0 +1,322 @@
/* 播放器 — 暖棕沉浸主题 */
::-webkit-scrollbar { display: none !important; }
.player-page {
height: 100vh;
background: #1A1208;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
/* 环境光 */
.bg-glow {
position: absolute;
top: 0; left: 0; right: 0;
height: 60vh;
pointer-events: none;
z-index: 0;
}
/* ── 顶部栏 ── */
.top-bar {
display: flex;
align-items: center;
padding: 16rpx 32rpx 12rpx;
position: relative;
z-index: 10;
}
.top-btn {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(255,255,255,0.06);
flex-shrink: 0;
}
.top-back {
font-size: 48rpx;
color: rgba(255,240,210,0.9);
font-weight: 300;
line-height: 1;
margin-top: -4rpx;
}
.top-share {
font-size: 32rpx;
color: rgba(255,220,160,0.6);
font-weight: 600;
}
.top-title-wrap {
flex: 1;
text-align: center;
overflow: hidden;
padding: 0 16rpx;
}
.top-label {
display: block;
font-size: 18rpx;
color: rgba(255,200,120,0.45);
letter-spacing: 2rpx;
margin-bottom: 6rpx;
}
.top-title {
display: block;
font-size: 26rpx;
font-weight: 700;
color: rgba(255,240,210,0.95);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── 中央 Banner ── */
.banner-area {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 10;
padding: 0 40rpx;
}
.banner-cover,
.banner-transcript {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
transition: opacity 0.35s ease, transform 0.35s ease;
opacity: 1;
transform: scale(1);
}
.banner-hidden {
opacity: 0;
transform: scale(0.96);
pointer-events: none;
}
/* ── 涟漪声波 ── */
.ripple-wrap {
position: absolute;
width: 300rpx;
height: 300rpx;
display: flex;
align-items: center;
justify-content: center;
}
.ripple-ring {
position: absolute;
width: 220rpx;
height: 220rpx;
border-radius: 50%;
border: 2rpx solid rgba(255, 157, 66, 0.5);
animation: ripple-out 2.1s ease-out infinite;
}
@keyframes ripple-out {
0% { transform: scale(0.6); opacity: 0.8; }
100% { transform: scale(2.4); opacity: 0; }
}
/* ── 大 Emoji(无卡片背景) ── */
.cover-emoji {
font-size: 180rpx;
position: relative;
z-index: 2;
transition: transform 0.45s cubic-bezier(0.34, 1.56, 0.64, 1);
transform: scale(0.9);
filter: drop-shadow(0 20rpx 40rpx rgba(0,0,0,0.5));
}
.cover-emoji.emoji-active {
transform: scale(1.05);
}
.cover-channel {
font-size: 22rpx;
font-weight: 600;
color: rgba(255,200,120,0.45);
letter-spacing: 2rpx;
margin-top: 24rpx;
position: relative;
z-index: 2;
}
/* 提示文字 */
.banner-hint {
display: block;
margin-top: 20rpx;
font-size: 20rpx;
color: rgba(255,200,120,0.3);
letter-spacing: 1rpx;
position: relative;
z-index: 2;
}
/* ── 文案视图 ── */
.banner-transcript {
padding: 24rpx 48rpx 16rpx;
flex-direction: column;
}
.transcript-scroll {
flex: 1;
width: 100%;
max-height: 480rpx;
background: rgba(255,200,120,0.05);
border-radius: 32rpx;
border: 1rpx solid rgba(255,200,120,0.12);
padding: 32rpx;
box-sizing: border-box;
}
.transcript-content {
font-size: 28rpx;
line-height: 1.95;
color: rgba(255,240,210,0.8);
white-space: pre-wrap;
}
.transcript-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 40rpx 0;
}
.transcript-empty-icon { font-size: 60rpx; }
.transcript-empty-text {
font-size: 24rpx;
color: rgba(255,200,120,0.4);
margin-top: 12rpx;
}
/* 日期 + like 同行 */
.date-like-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 48rpx;
margin-bottom: 4rpx;
position: relative;
z-index: 10;
}
.date-text {
font-size: 22rpx;
color: rgba(255,200,120,0.4);
letter-spacing: 1rpx;
}
.like-btn-inline {
display: flex;
align-items: center;
justify-content: center;
padding: 4rpx 0;
}
.like-icon {
font-size: 40rpx;
color: rgba(255,200,120,0.55);
}
/* ── 进度条 + like 浮动 ── */
.progress-section {
padding: 0 48rpx;
margin-bottom: 16rpx;
position: relative;
z-index: 10;
}
/* like 按钮已内联至 time-row,旧绝对定位样式已移除 */
.progress-slider { margin: 0; }
.time-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8rpx;
}
.time-text {
font-size: 22rpx;
color: rgba(255,200,120,0.4);
font-family: 'Menlo', 'SF Mono', monospace;
font-weight: 600;
}
/* like 按鈕内联在时间行中间 */
.like-btn-inline {
display: flex;
align-items: center;
justify-content: center;
padding: 4rpx 16rpx;
}
.like-icon {
font-size: 40rpx;
color: rgba(255,200,120,0.55);
}
/* ── 控制区 ── */
.controls-row {
display: flex;
align-items: center;
justify-content: center;
gap: 64rpx;
padding: 16rpx 48rpx 64rpx;
position: relative;
z-index: 10;
}
.skip-btn {
width: 96rpx;
height: 96rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
}
.skip-arrow {
font-size: 76rpx;
color: rgba(255,220,160,0.8);
line-height: 1;
}
.skip-sec {
position: absolute;
font-size: 19rpx;
font-weight: 800;
color: rgba(255,220,160,0.8);
top: 50%;
left: 50%;
transform: translate(-50%, -46%);
letter-spacing: -1rpx;
}
.play-btn {
width: 136rpx;
height: 136rpx;
border-radius: 50%;
background: #FF9D42;
display: flex;
align-items: center;
justify-content: center;
box-shadow:
0 16rpx 48rpx rgba(255, 157, 66, 0.5),
0 0 0 12rpx rgba(255, 157, 66, 0.15);
transition: transform 0.15s, box-shadow 0.15s;
}
.play-btn:active {
transform: scale(0.92);
box-shadow:
0 8rpx 24rpx rgba(255, 157, 66, 0.4),
0 0 0 8rpx rgba(255, 157, 66, 0.1);
}
.play-tri {
font-size: 52rpx;
color: #FFF8EE;
margin-left: 8rpx;
line-height: 1;
}
.pause-group {
display: flex;
gap: 12rpx;
align-items: center;
}
.pause-bar {
width: 10rpx;
height: 44rpx;
background: #FFF8EE;
border-radius: 5rpx;
}
+99
View File
@@ -0,0 +1,99 @@
/**
* 个人中心 — 用户信息 + 订阅管理 + 菜单
* 从后端获取已订阅频道列表
*/
const app = getApp()
const api = require('../../utils/api')
Page({
data: {
isVip: false,
userInfo: null,
subscribedData: [],
menuItems: [
{ id: 'vip', label: '会员中心', icon: '👑', desc: '未开通', highlight: true },
{ id: 'help', label: '帮助与反馈', icon: '❓', desc: '', highlight: false },
{ id: 'about', label: '关于我们', icon: '📄', desc: 'v1.0.0', highlight: false }
]
},
onShow() {
this._refresh()
this._onSubChange = () => this._refresh()
this._onVipChange = () => this._refresh()
app.on('subscriptionChange', this._onSubChange)
app.on('vipChange', this._onVipChange)
},
onHide() {
if (this._onSubChange) app.off('subscriptionChange', this._onSubChange)
if (this._onVipChange) app.off('vipChange', this._onVipChange)
},
_refresh() {
const self = this
const gd = app.globalData
this.setData({
isVip: gd.isVip,
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 ? '已开通' : '未开通'
menuItems[0].highlight = !gd.isVip
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') {
wx.navigateTo({ url: '/pages/vip/index' })
} else if (id === 'help') {
wx.showToast({ title: '帮助中心开发中', icon: 'none' })
} else if (id === 'about') {
wx.showToast({ title: '早安电台 v1.0.0', icon: 'none' })
}
},
goDiscover() {
wx.switchTab({ url: '/pages/discover/index' })
}
})
+6
View File
@@ -0,0 +1,6 @@
{
"usingComponents": {
"global-player": "/components/global-player/index"
},
"navigationBarTitleText": "我的"
}
+85
View File
@@ -0,0 +1,85 @@
<!-- 个人中心 —— 用户信息 + 订阅管理 + 菜单 -->
<view class="profile-page">
<!-- 橙色头部背景 -->
<view class="header-bg">
<view class="user-info">
<!-- 头像 -->
<view class="avatar-wrap">
<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>
</view>
</view>
</view>
</view>
<!-- 菜单列表 -->
<view class="menu-card-wrap">
<view class="card menu-card">
<view
wx:for="{{menuItems}}"
wx:key="id"
class="menu-item tap-active"
bindtap="onMenuTap"
data-id="{{item.id}}"
>
<view class="menu-item-left">
<view class="menu-icon-wrap {{item.highlight ? 'highlight' : ''}}">
<text class="menu-emoji">{{item.icon}}</text>
</view>
<text class="menu-label">{{item.label}}</text>
</view>
<view class="menu-item-right">
<text class="menu-desc {{item.highlight ? 'highlight' : ''}}">{{item.desc}}</text>
<text class="menu-arrow"></text>
</view>
</view>
</view>
</view>
<view style="height: 200rpx;"></view>
<global-player />
</view>
+223
View File
@@ -0,0 +1,223 @@
/* 个人中心样式 */
.profile-page {
min-height: 100vh;
background: var(--color-bg-page);
}
/* 橙色头部 */
.header-bg {
background: #F38600;
border-radius: 0 0 48rpx 48rpx;
padding-bottom: 200rpx;
}
.user-info {
display: flex;
flex-direction: column;
align-items: center;
padding: 32rpx 40rpx;
}
.avatar-wrap {
width: 128rpx;
height: 128rpx;
border-radius: 50%;
border: 6rpx solid rgba(255, 255, 255, 0.3);
background: linear-gradient(135deg, #FFB366, #FFE0B2);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20rpx;
overflow: hidden;
}
.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;
}
/* 订阅卡片 */
.sub-card-wrap {
padding: 0 32rpx;
margin-top: -140rpx;
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 {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx;
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;
}
.sub-del {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: #F5F5F5;
border: 1rpx solid #EEE;
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;
font-weight: 500;
}
/* 菜单卡片 */
.menu-card-wrap {
padding: 24rpx 32rpx 0;
}
.menu-card {
padding: 8rpx 0;
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 32rpx;
border-bottom: 1rpx solid rgba(0,0,0,0.03);
}
.menu-item:last-child {
border-bottom: none;
}
.menu-item-left {
display: flex;
align-items: center;
}
.menu-icon-wrap {
width: 52rpx;
height: 52rpx;
border-radius: 50%;
background: #F5F5F5;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.menu-icon-wrap.highlight {
background: rgba(251, 191, 36, 0.1);
}
.menu-emoji {
font-size: 24rpx;
}
.menu-label {
font-size: 28rpx;
font-weight: 700;
color: #333;
letter-spacing: 2rpx;
}
.menu-item-right {
display: flex;
align-items: center;
gap: 12rpx;
}
.menu-desc {
font-size: 22rpx;
color: #999;
font-weight: 500;
}
.menu-desc.highlight {
color: #D97706;
}
.menu-arrow {
font-size: 28rpx;
color: #CCC;
}
+41
View File
@@ -0,0 +1,41 @@
/**
* 启动页 — 每次启动都执行 wx.login → 后端登录
* 微信的 code 是一次性的,必须每次重新获取
*/
const app = getApp()
Page({
data: {
loginState: 'loading' // 'loading' | 'success' | 'error'
},
onLoad() {
// 每次进入启动页都执行登录流程(不跳过)
this._startLogin()
},
/**
* 执行静默登录
* wx.login() → code → 后端 /auth/miniLogin → token + user
*/
_startLogin() {
const self = this
app.login().then(function (data) {
self.setData({ loginState: 'success' })
// 登录成功,多停留一会儿再跳转(让用户看清启动页)
setTimeout(function () {
wx.switchTab({ url: '/pages/index/index' })
}, 1800)
}).catch(function (err) {
console.error('[Splash] 登录失败:', err)
self.setData({ loginState: 'error' })
// 登录失败也跳转首页(游客模式浏览)
setTimeout(function () {
wx.switchTab({ url: '/pages/index/index' })
}, 1500)
})
}
})
+4
View File
@@ -0,0 +1,4 @@
{
"usingComponents": {},
"navigationStyle": "custom"
}
+45
View File
@@ -0,0 +1,45 @@
<!-- 启动页 —— 沉浸式暖棕主题 -->
<page-meta page-style="overflow:hidden; background:#1A1208;" />
<view class="splash-page">
<!-- 环境光 -->
<view class="splash-glow"></view>
<!-- 主内容 -->
<view class="splash-content {{loginState === 'success' ? 'fade-out' : ''}}">
<!-- 收音机图标 + 脉冲 -->
<view class="logo-wrap">
<view class="pulse-ring" wx:if="{{loginState === 'loading'}}"></view>
<view class="pulse-ring pulse-ring-2" wx:if="{{loginState === 'loading'}}"></view>
<text class="logo-icon">📻</text>
</view>
<!-- 应用名 + slogan -->
<text class="app-title">全声汇</text>
<text class="app-sub">QuanShengHui</text>
<text class="app-slogan">好内容,随时听</text>
<!-- 状态 -->
<view class="status-area">
<view wx:if="{{loginState === 'loading'}}" class="status-row">
<view class="loading-dots">
<view class="dot" style="animation-delay:0s"></view>
<view class="dot" style="animation-delay:0.2s"></view>
<view class="dot" style="animation-delay:0.4s"></view>
</view>
</view>
<view wx:elif="{{loginState === 'success'}}" class="status-row">
<text class="status-check">✓</text>
<text class="status-ok">已就绪</text>
</view>
</view>
</view>
<!-- 底部版权 -->
<view class="splash-footer">
<text class="footer-text">每天三分钟,精准获取所需</text>
</view>
</view>
+152
View File
@@ -0,0 +1,152 @@
/* 启动页 — 暖棕沉浸主题,与播放器保持一致 */
.splash-page {
width: 100%;
height: 100vh;
background: #1A1208;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
}
/* 环境光晕 */
.splash-glow {
position: absolute;
top: -10vh;
left: 50%;
transform: translateX(-50%);
width: 120vw;
height: 60vh;
background: radial-gradient(ellipse at center, rgba(255,157,66,0.22) 0%, transparent 70%);
pointer-events: none;
}
/* 主内容容器 */
.splash-content {
display: flex;
flex-direction: column;
align-items: center;
transition: opacity 1.0s ease, transform 1.0s ease, filter 1.0s ease;
}
.splash-content.fade-out {
opacity: 0;
transform: scale(1.06) translateY(-12rpx);
filter: blur(6px);
}
/* ── 图标 ── */
.logo-wrap {
width: 200rpx;
height: 200rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 64rpx;
position: relative;
}
.logo-icon {
font-size: 120rpx;
filter: drop-shadow(0 16rpx 40rpx rgba(255,120,0,0.4));
position: relative;
z-index: 2;
}
/* 双层脉冲环 */
.pulse-ring {
position: absolute;
inset: -20rpx;
border-radius: 50%;
border: 3rpx solid rgba(255, 157, 66, 0.45);
animation: pulse-expand 2s ease-out infinite;
}
.pulse-ring-2 {
animation-delay: 1s;
}
@keyframes pulse-expand {
0% { transform: scale(0.7); opacity: 0.8; }
100% { transform: scale(1.6); opacity: 0; }
}
/* ── 文字 ── */
.app-title {
font-size: 64rpx;
font-weight: 800;
color: rgba(255, 240, 210, 0.95);
letter-spacing: -2rpx;
margin-bottom: 8rpx;
}
.app-sub {
font-size: 22rpx;
font-weight: 400;
color: rgba(255, 200, 120, 0.45);
letter-spacing: 6rpx;
margin-bottom: 28rpx;
text-transform: uppercase;
}
.app-slogan {
font-size: 26rpx;
font-weight: 400;
color: rgba(255, 200, 120, 0.55);
letter-spacing: 3rpx;
}
/* ── 状态区 ── */
.status-area {
margin-top: 100rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
}
.status-row {
display: flex;
align-items: center;
gap: 12rpx;
}
/* 三点加载动画 */
.loading-dots {
display: flex;
gap: 12rpx;
align-items: center;
}
.dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: rgba(255, 157, 66, 0.7);
animation: dot-bounce 1.2s ease-in-out infinite alternate;
}
@keyframes dot-bounce {
0% { transform: translateY(0); opacity: 0.3; }
100% { transform: translateY(-12rpx); opacity: 1; }
}
/* 成功状态 */
.status-check {
font-size: 32rpx;
color: #FF9D42;
font-weight: 700;
}
.status-ok {
font-size: 26rpx;
color: rgba(255, 200, 120, 0.7);
font-weight: 500;
}
/* ── 底部版权 ── */
.splash-footer {
position: absolute;
bottom: 80rpx;
left: 0;
right: 0;
text-align: center;
}
.footer-text {
font-size: 20rpx;
color: rgba(255, 200, 120, 0.2);
letter-spacing: 2rpx;
}
+100
View File
@@ -0,0 +1,100 @@
/**
* 订阅支付页
* 接收参数:channelId, channelName, monthlyPrice, quarterlyPrice, annualPrice
*/
const app = getApp()
const api = require('../../utils/api')
Page({
data: {
channelId: '',
channelName: '',
monthlyPrice: 0,
quarterlyPrice: 0,
annualPrice: 0,
selectedPlan: '', // 'monthly' | 'quarterly' | 'annual'
currentPrice: 0
},
onLoad(options) {
// 后端价格单位为「分」,除以 100 转为「元」
const monthly = (parseFloat(options.monthlyPrice) || 0) / 100
const quarterly = (parseFloat(options.quarterlyPrice) || 0) / 100
const annual = (parseFloat(options.annualPrice) || 0) / 100
// 默认选中"包年"(如果有),否则最便宜的
let defaultPlan = ''
let defaultPrice = 0
if (annual > 0) {
defaultPlan = 'annual'
defaultPrice = annual
} else if (quarterly > 0) {
defaultPlan = 'quarterly'
defaultPrice = quarterly
} else if (monthly > 0) {
defaultPlan = 'monthly'
defaultPrice = monthly
}
this.setData({
channelId: options.channelId || '',
channelName: decodeURIComponent(options.channelName || ''),
monthlyPrice: monthly,
quarterlyPrice: quarterly,
annualPrice: annual,
selectedPlan: defaultPlan,
currentPrice: defaultPrice,
// 预计算节省金额和月均价(WXML 不支持 .toFixed()
_quarterlySaving: monthly > 0 && quarterly > 0 ? Math.round(monthly * 3 - quarterly) : 0,
_quarterlyMonthly: quarterly > 0 ? (quarterly / 3).toFixed(1) : '0',
_annualSaving: monthly > 0 && annual > 0 ? Math.round(monthly * 12 - annual) : 0,
_annualMonthly: annual > 0 ? (annual / 12).toFixed(1) : '0'
})
},
/** 选择套餐 */
selectPlan(e) {
const plan = e.currentTarget.dataset.plan
const priceMap = {
monthly: this.data.monthlyPrice,
quarterly: this.data.quarterlyPrice,
annual: this.data.annualPrice
}
this.setData({
selectedPlan: plan,
currentPrice: priceMap[plan] || 0
})
},
/** 发起支付 */
onPay() {
const { channelId, selectedPlan, currentPrice, channelName } = this.data
if (!selectedPlan) {
wx.showToast({ title: '请选择订阅方案', icon: 'none' })
return
}
// 调用订阅/支付接口
wx.showLoading({ title: '处理中...' })
api.subscribeChannel({
channelId,
plan: selectedPlan,
price: currentPrice
}).then(function (res) {
wx.hideLoading()
if (res.code === 200) {
wx.showToast({ title: '订阅成功!', icon: 'success' })
setTimeout(function () {
wx.navigateBack()
}, 1500)
} else {
wx.showToast({ title: res.msg || '订阅失败', icon: 'none' })
}
}).catch(function (err) {
wx.hideLoading()
console.error('[订阅] 失败:', err)
wx.showToast({ title: '网络异常,请重试', icon: 'none' })
})
}
})
+7
View File
@@ -0,0 +1,7 @@
{
"usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon",
"t-tag": "tdesign-miniprogram/tag/tag"
},
"navigationBarTitleText": "订阅频道"
}
+98
View File
@@ -0,0 +1,98 @@
<!-- 订阅支付页 -->
<page-meta page-style="overflow: hidden;" />
<view class="subscribe-page">
<!-- 顶部频道信息 -->
<view class="channel-card">
<view class="channel-avatar">
<text class="channel-avatar-emoji">📻</text>
</view>
<text class="channel-title">{{channelName}}</text>
<text class="channel-desc">选择适合你的订阅方案</text>
</view>
<!-- 价格套餐选择 -->
<view class="plans-wrap">
<!-- 包月 -->
<view
wx:if="{{monthlyPrice > 0}}"
class="plan-card {{selectedPlan === 'monthly' ? 'selected' : ''}}"
bindtap="selectPlan"
data-plan="monthly"
>
<view class="plan-top">
<text class="plan-name">包月</text>
<view class="plan-badge" wx:if="{{selectedPlan === 'monthly'}}">
<t-icon name="check-circle-filled" size="36rpx" color="#FF9D42" />
</view>
</view>
<text class="plan-price">¥{{monthlyPrice}}</text>
<text class="plan-unit">/ 月</text>
</view>
<!-- 包季 -->
<view
wx:if="{{quarterlyPrice > 0}}"
class="plan-card {{selectedPlan === 'quarterly' ? 'selected' : ''}}"
bindtap="selectPlan"
data-plan="quarterly"
>
<view class="plan-top">
<text class="plan-name">包季</text>
<t-tag wx:if="{{quarterlyPrice > 0 && monthlyPrice > 0}}" size="small" variant="filled" theme="success">省{{_quarterlySaving}}元</t-tag>
<view class="plan-badge" wx:if="{{selectedPlan === 'quarterly'}}">
<t-icon name="check-circle-filled" size="36rpx" color="#FF9D42" />
</view>
</view>
<text class="plan-price">¥{{quarterlyPrice}}</text>
<text class="plan-unit">/ 季 · 约¥{{_quarterlyMonthly}}/月</text>
</view>
<!-- 包年 -->
<view
wx:if="{{annualPrice > 0}}"
class="plan-card popular {{selectedPlan === 'annual' ? 'selected' : ''}}"
bindtap="selectPlan"
data-plan="annual"
>
<view class="plan-hot-tag">最受欢迎</view>
<view class="plan-top">
<text class="plan-name">包年</text>
<t-tag wx:if="{{annualPrice > 0 && monthlyPrice > 0}}" size="small" variant="filled" theme="success">省{{_annualSaving}}元</t-tag>
<view class="plan-badge" wx:if="{{selectedPlan === 'annual'}}">
<t-icon name="check-circle-filled" size="36rpx" color="#FF9D42" />
</view>
</view>
<text class="plan-price">¥{{annualPrice}}</text>
<text class="plan-unit">/ 年 · 约¥{{_annualMonthly}}/月</text>
</view>
</view>
<!-- 权益说明 -->
<view class="benefits">
<view class="benefit-item">
<t-icon name="check" size="28rpx" color="#FF9D42" />
<text>订阅后可无限收听该频道所有节目</text>
</view>
<view class="benefit-item">
<t-icon name="check" size="28rpx" color="#FF9D42" />
<text>后台播放,边听边做其他事</text>
</view>
<view class="benefit-item">
<t-icon name="check" size="28rpx" color="#FF9D42" />
<text>新节目第一时间推送通知</text>
</view>
</view>
<!-- 底部支付按钮 -->
<view class="pay-bar">
<view class="pay-amount" wx:if="{{selectedPlan}}">
<text class="pay-label">合计</text>
<text class="pay-price">¥{{currentPrice}}</text>
</view>
<view class="pay-btn tap-active" bindtap="onPay">
<text>立即订阅</text>
</view>
</view>
</view>
+147
View File
@@ -0,0 +1,147 @@
/* 订阅支付页 */
.subscribe-page {
min-height: 100vh;
background: #F6F6F6;
display: flex;
flex-direction: column;
padding-bottom: 160rpx;
}
/* 顶部频道卡片 */
.channel-card {
background: linear-gradient(135deg, #FF9D42, #FF7832);
padding: 60rpx 40rpx 48rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.channel-avatar {
width: 120rpx;
height: 120rpx;
background: rgba(255, 255, 255, 0.25);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20rpx;
}
.channel-avatar-emoji { font-size: 56rpx; }
.channel-title {
font-size: 36rpx;
font-weight: 800;
color: #FFF;
margin-bottom: 8rpx;
}
.channel-desc {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
}
/* 套餐区域 */
.plans-wrap {
padding: 32rpx 32rpx 20rpx;
display: flex;
flex-direction: column;
gap: 20rpx;
}
.plan-card {
background: #FFF;
border-radius: 28rpx;
padding: 32rpx 36rpx;
border: 3rpx solid transparent;
position: relative;
transition: all 0.2s;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
}
.plan-card.selected {
border-color: #FF9D42;
background: #FFFAF5;
}
.plan-card.popular {
border-color: #FFD580;
}
.plan-card.popular.selected {
border-color: #FF9D42;
}
/* 最受欢迎标签 */
.plan-hot-tag {
position: absolute;
top: -20rpx;
left: 36rpx;
background: linear-gradient(135deg, #FF9D42, #FF7832);
color: #FFF;
font-size: 20rpx;
font-weight: 700;
padding: 6rpx 20rpx;
border-radius: 999rpx;
}
.plan-top {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 12rpx;
}
.plan-name {
font-size: 28rpx;
font-weight: 700;
color: #222;
flex: 1;
}
.plan-badge { margin-left: auto; }
.plan-price {
font-size: 52rpx;
font-weight: 800;
color: #FF7832;
line-height: 1;
}
.plan-unit {
font-size: 22rpx;
color: #999;
margin-top: 6rpx;
display: block;
}
/* 权益说明 */
.benefits {
margin: 0 32rpx;
background: #FFF;
border-radius: 24rpx;
padding: 28rpx 32rpx;
}
.benefit-item {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 18rpx;
font-size: 26rpx;
color: #444;
}
.benefit-item:last-child { margin-bottom: 0; }
/* 底部支付栏 */
.pay-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #FFF;
padding: 20rpx 32rpx 48rpx;
display: flex;
align-items: center;
gap: 24rpx;
border-top: 1rpx solid #F0F0F0;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.05);
}
.pay-amount { flex: 1; }
.pay-label { font-size: 22rpx; color: #999; display: block; }
.pay-price { font-size: 44rpx; font-weight: 800; color: #FF7832; line-height: 1; }
.pay-btn {
background: linear-gradient(135deg, #FF9D42, #FF7832);
color: #FFF;
font-size: 30rpx;
font-weight: 700;
padding: 24rpx 64rpx;
border-radius: 999rpx;
box-shadow: 0 8rpx 24rpx rgba(255, 120, 50, 0.35);
}
.pay-btn:active { transform: scale(0.96); opacity: 0.9; }
+222
View File
@@ -0,0 +1,222 @@
/**
* 会员/订阅中心
*
* 支持两种入口模式:
* mode=vip(默认): 开通全频道会员
* mode=channel : 订阅指定频道,URL 需带 channelId/channelName/monthlyPrice/quarterlyPrice/annualPrice
*/
const app = getApp()
const api = require('../../utils/api')
Page({
data: {
isVip: false,
// 模式:'vip' | 'channel'
mode: 'vip',
// 频道订阅模式下的频道信息
channelId: '',
channelName: '',
monthlyPrice: 0,
quarterlyPrice: 0,
annualPrice: 0,
// 预计算(WXML 不支持方法调用)
_quarterlySaving: 0,
_quarterlyMonthly: '0',
_annualSaving: 0,
_annualMonthly: '0',
// 当前选中的套餐,vip 模式固定 'vip-all'channel 模式为 'monthly'|'quarterly'|'annual'
selectedPlan: 'vip-all',
currentPrice: '19.9'
},
onLoad(options) {
const isChannelMode = !!options.channelId
if (isChannelMode) {
// ─── 频道订阅模式 ───
const monthly = (parseFloat(options.monthlyPrice) || 0) / 100
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 }
else if (quarterly > 0) { defaultPlan = 'quarterly'; defaultPrice = quarterly }
this.setData({
mode: 'channel',
channelId: options.channelId,
channelName: decodeURIComponent(options.channelName || ''),
monthlyPrice: monthly,
quarterlyPrice: quarterly,
annualPrice: annual,
selectedPlan: defaultPlan,
currentPrice: defaultPrice.toFixed(2),
_quarterlySaving: (monthly > 0 && quarterly > 0) ? Math.round(monthly * 3 - quarterly) : 0,
_quarterlyMonthly: quarterly > 0 ? (quarterly / 3).toFixed(1) : '0',
_annualSaving: (monthly > 0 && annual > 0) ? Math.round(monthly * 12 - annual) : 0,
_annualMonthly: annual > 0 ? (annual / 12).toFixed(1) : '0'
})
} else {
// ─── VIP 会员模式 ───
this.setData({
mode: 'vip',
isVip: app.globalData.isVip,
selectedPlan: 'vip-all',
currentPrice: '19.9'
})
}
},
onShow() {
if (this.data.mode === 'vip') {
this.setData({ isVip: app.globalData.isVip })
}
},
/** 选择套餐 */
selectPlan(e) {
const plan = e.currentTarget.dataset.plan
let price = '19.9'
if (this.data.mode === 'channel') {
const map = {
monthly: this.data.monthlyPrice,
quarterly: this.data.quarterlyPrice,
annual: this.data.annualPrice
}
price = (map[plan] || 0).toFixed(2)
}
this.setData({ selectedPlan: plan, currentPrice: price })
},
/** 发起支付 */
onPay() {
const self = this
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)
}
}
})
return
}
// ── 频道订阅:唤起微信支付 ──
if (!selectedPlan) {
wx.showToast({ title: '请选择订阅方案', icon: 'none' })
return
}
// 套餐 → type 映射(后端约定:1=包月 2=包季 3=包年)
const typeMap = { monthly: '1', quarterly: '2', annual: '3' }
const payType = typeMap[selectedPlan]
if (!payType) {
wx.showToast({ title: '未知套餐类型', icon: 'none' })
return
}
wx.showLoading({ title: '获取支付信息...' })
api.unlockChannel(channelId, payType)
.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() {
// 支付 UI 完成后,主动轮询查询支付结果
// 策略:立即查一次,失败则间隔 2s 重试,最多 3 次
// 原因:微信回调是异步的,可能比 success 回调晚几秒到
self._pollPayStatus(outTradeNo, 3, 2000)
},
fail(err) {
if (err.errMsg && err.errMsg.indexOf('cancel') > -1) {
return // 用户主动取消,静默处理
}
wx.showToast({ title: '支付失败,请重试', icon: 'none' })
console.error('[支付] wx.requestPayment 失败:', err)
}
})
})
.catch(function (err) {
wx.hideLoading()
console.error('[支付] unlockChannel 请求失败:', err)
wx.showToast({ title: '网络异常,请重试', icon: 'none' })
})
},
/**
* 轮询支付状态
* @param {string} outTradeNo 商户订单号
* @param {number} retries 剩余重试次数
* @param {number} interval 每次重试间隔(ms
*/
_pollPayStatus(outTradeNo, retries, interval) {
const self = this
wx.showLoading({ title: '验证中...' })
api.queryPayStatus(outTradeNo)
.then(function (paid) {
if (paid) {
// ✅ 支付确认成功
wx.hideLoading()
wx.showToast({ title: '订阅成功!', icon: 'success' })
app.emit('subscriptionChange')
setTimeout(function () { wx.navigateBack() }, 1500)
} else if (retries > 1) {
// 🔄 尚未到账,等待后重试(回调可能还在路上)
setTimeout(function () {
self._pollPayStatus(outTradeNo, retries - 1, interval)
}, interval)
} else {
// ⏳ 重试耗尽:回调可能仍在处理,给用户友好提示后返回
wx.hideLoading()
wx.showModal({
title: '支付处理中',
content: '支付已完成,订阅正在确认中,稍后请刷新查看',
showCancel: false,
success: function () { wx.navigateBack() }
})
}
})
.catch(function (err) {
wx.hideLoading()
console.error('[支付] 查询状态失败:', err)
// 查询本身网络失败,乐观处理——让后端 webhook 兜底
wx.showModal({
title: '支付处理中',
content: '支付已提交,订阅确认中,稍后请刷新查看',
showCancel: false,
success: function () { wx.navigateBack() }
})
})
},
goBack() {
wx.navigateBack()
}
})
+8
View File
@@ -0,0 +1,8 @@
{
"usingComponents": {
"t-button": "tdesign-miniprogram/button/button",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-tag": "tdesign-miniprogram/tag/tag"
},
"navigationBarTitleText": "开通会员"
}
+209
View File
@@ -0,0 +1,209 @@
<!-- 会员/订阅中心 -->
<page-meta page-style="overflow: hidden;" />
<view class="vip-page">
<!-- ── 已是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>
<!-- ── 主Scroll区域 ── -->
<scroll-view
wx:else
scroll-y
enhanced
show-scrollbar="{{false}}"
class="vip-scroll"
>
<!-- 头部区域,根据模式切换文案 -->
<view class="vip-hero">
<text class="vip-hero-title">{{mode === 'channel' ? channelName : '开通全频道会员'}}</text>
<text class="vip-hero-desc">
{{mode === 'channel' ? '选择适合你的订阅方案,随时随地收听' : '解锁全部频道,告别无聊早晨'}}
</text>
</view>
<!-- ────── MODE: VIP 全频道 ────── -->
<block wx:if="{{mode === 'vip'}}">
<!-- 权益卡片 -->
<view class="benefits-card-wrap">
<view class="card benefits-card">
<view class="benefits-title">
<text class="benefit-crown">👑</text>
<text>会员专属特权</text>
</view>
<view class="benefits-grid">
<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 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 class="plan-section">
<text class="plan-title">选择套餐</text>
<view
class="plan-card {{selectedPlan === 'vip-all' ? 'selected' : ''}}"
bindtap="selectPlan"
data-plan="vip-all"
>
<view wx:if="{{selectedPlan === 'vip-all'}}" class="plan-badge">限时特惠</view>
<view class="plan-info">
<text class="plan-name">全频道连续包月</text>
<text class="plan-desc">自动续费,随时可取消</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>
</view>
</view>
</view>
</block>
<!-- ────── MODE: 频道订阅 ────── -->
<block wx:else>
<!-- 权益说明 -->
<view class="benefits-card-wrap">
<view class="card benefits-card">
<view class="benefits-title">
<text class="benefit-crown">🎙️</text>
<text>订阅后专属权益</text>
</view>
<view class="benefit-item" style="width:100%; margin-bottom:16rpx;">
<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" style="width:100%; margin-bottom:16rpx;">
<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" style="width:100%;">
<text class="benefit-check">🎧</text>
<view class="benefit-info">
<text class="benefit-name">后台播放,不中断</text>
<text class="benefit-desc">边听边做其他事</text>
</view>
</view>
</view>
</view>
<!-- 价格套餐 -->
<view class="plan-section">
<text class="plan-title">选择方案</text>
<!-- 包月 -->
<view
wx:if="{{monthlyPrice > 0}}"
class="plan-card {{selectedPlan === 'monthly' ? 'selected' : ''}}"
bindtap="selectPlan"
data-plan="monthly"
>
<view class="plan-info">
<text class="plan-name">包月</text>
<text class="plan-desc">按月订阅,随时取消</text>
</view>
<view class="plan-price">
<text class="price-amount"><text class="price-symbol">¥</text>{{monthlyPrice}}</text>
<text class="price-original">/ 月</text>
</view>
</view>
<!-- 包季 -->
<view
wx:if="{{quarterlyPrice > 0}}"
class="plan-card {{selectedPlan === 'quarterly' ? 'selected' : ''}}"
bindtap="selectPlan"
data-plan="quarterly"
>
<view wx:if="{{_quarterlySaving > 0}}" class="plan-badge plan-badge-save">省{{_quarterlySaving}}元</view>
<view class="plan-info">
<text class="plan-name">包季</text>
<text class="plan-desc">约¥{{_quarterlyMonthly}}/月,比包月更划算</text>
</view>
<view class="plan-price">
<text class="price-amount"><text class="price-symbol">¥</text>{{quarterlyPrice}}</text>
<text class="price-original">/ 季</text>
</view>
</view>
<!-- 包年 -->
<view
wx:if="{{annualPrice > 0}}"
class="plan-card {{selectedPlan === 'annual' ? 'selected' : ''}}"
bindtap="selectPlan"
data-plan="annual"
>
<view class="plan-badge plan-badge-hot">最受欢迎</view>
<view wx:if="{{_annualSaving > 0}}" class="plan-badge plan-badge-save" style="right: 96rpx;">省{{_annualSaving}}元</view>
<view class="plan-info">
<text class="plan-name">包年</text>
<text class="plan-desc">约¥{{_annualMonthly}}/月,最优惠</text>
</view>
<view class="plan-price">
<text class="price-amount"><text class="price-symbol">¥</text>{{annualPrice}}</text>
<text class="price-original">/ 年</text>
</view>
</view>
</view>
</block>
</scroll-view>
<!-- 底部支付栏(scroll-view 外,flex 布局固定底部,真机可靠) -->
<view class="pay-bar" wx:if="{{!isVip || mode === 'channel'}}">
<view class="pay-summary">
<text class="pay-label">总计:</text>
<text class="pay-amount">¥{{currentPrice}}</text>
</view>
<view class="pay-method">
<text class="pay-method-text">使用 微信支付 支付</text>
<text class="wechat-check">✓</text>
</view>
<t-button
theme="primary"
shape="round"
block
size="large"
bind:tap="onPay"
style="--td-button-primary-bg-color: #FF9D42; --td-button-primary-active-bg-color: #E88A35;"
>
{{mode === 'channel' ? '立即订阅并支付' : '立即开通并支付'}}
</t-button>
</view>
</view>
+287
View File
@@ -0,0 +1,287 @@
/* 会员中心样式 */
::-webkit-scrollbar { display: none !important; width: 0 !important; height: 0 !important; }
.vip-page {
display: flex;
flex-direction: column;
height: 100vh;
background: #FCFCFC;
overflow: hidden;
}
/* 已是VIP */
.vip-done {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
padding: 40rpx;
background: #FFFFFF;
}
.vip-done-icon {
font-size: 120rpx;
margin-bottom: 40rpx;
}
.vip-done-title {
font-size: 36rpx;
font-weight: 700;
color: #333;
margin-bottom: 16rpx;
}
.vip-done-desc {
font-size: 28rpx;
color: #999;
margin-bottom: 48rpx;
}
.done-back-btn {
padding: 16rpx 48rpx;
background: #F5F5F5;
border-radius: 999rpx;
font-size: 28rpx;
font-weight: 500;
border: none;
color: #666;
}
.done-back-btn::after { border: none; }
/* 可滚动内容区 */
.vip-scroll {
flex: 1;
overflow: hidden;
}
/* 支付栏(flex 子元素,不用 position:fixed,真机更可靠) */
.pay-bar {
flex-shrink: 0;
background: #FFF;
border-top: 1rpx solid #F5F5F5;
padding: 20rpx 32rpx 48rpx;
box-shadow: 0 -8rpx 32rpx rgba(0, 0, 0, 0.05);
}
/* 深色头部 */
.vip-hero {
background: linear-gradient(to bottom, #1F2937, #111827);
padding: 20rpx 40rpx 80rpx;
border-radius: 0 0 64rpx 64rpx;
box-shadow: 0 16rpx 40rpx rgba(0, 0, 0, 0.2);
position: relative;
}
.back-btn {
position: absolute;
top: 0;
left: 24rpx;
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.back-arrow {
font-size: 48rpx;
color: #FFF;
font-weight: 300;
}
.vip-hero-title {
display: block;
font-size: 52rpx;
font-weight: 800;
color: #FFF;
letter-spacing: -2rpx;
margin-bottom: 12rpx;
margin-top: 48rpx;
}
.vip-hero-desc {
display: block;
font-size: 28rpx;
font-weight: 500;
color: #FCD34D;
}
/* 权益卡片 */
.benefits-card-wrap {
padding: 0 32rpx;
margin-top: -40rpx;
position: relative;
z-index: 10;
}
.benefits-card {
padding: 36rpx;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.06);
border: 1rpx solid rgba(251, 191, 36, 0.15);
}
.benefits-title {
font-size: 28rpx;
font-weight: 700;
color: #333;
display: flex;
align-items: center;
margin-bottom: 28rpx;
}
.benefit-crown {
font-size: 36rpx;
margin-right: 12rpx;
}
.benefits-grid {
display: flex;
flex-wrap: wrap;
gap: 28rpx 16rpx;
}
.benefit-item {
width: calc(50% - 8rpx);
display: flex;
align-items: flex-start;
}
.benefit-check {
font-size: 28rpx;
margin-right: 12rpx;
flex-shrink: 0;
margin-top: 4rpx;
}
.benefit-info {
flex: 1;
}
.benefit-name {
display: block;
font-size: 24rpx;
font-weight: 700;
color: #333;
line-height: 1.3;
}
.benefit-desc {
display: block;
font-size: 20rpx;
color: #999;
margin-top: 4rpx;
}
/* 套餐 */
.plan-section {
padding: 32rpx 32rpx 0;
}
.plan-title {
display: block;
font-size: 28rpx;
font-weight: 700;
color: #333;
margin-bottom: 24rpx;
padding-left: 8rpx;
}
.plan-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx;
border: 4rpx solid #F0F0F0;
border-radius: 24rpx;
background: #FFF;
margin-bottom: 16rpx;
position: relative;
transition: all 0.2s;
}
.plan-card.selected {
border-color: #FBBF24;
background: rgba(251, 191, 36, 0.08);
}
.plan-badge {
position: absolute;
top: 0;
right: 0;
background: #FBBF24;
color: #1F2937;
font-size: 20rpx;
font-weight: 700;
padding: 6rpx 20rpx;
border-radius: 0 20rpx 0 20rpx;
}
.plan-badge-hot {
background: linear-gradient(135deg, #FF9D42, #FF7832);
color: #FFF;
}
.plan-badge-save {
background: #2ECC71;
color: #FFF;
border-radius: 0 0 0 20rpx;
right: 0;
top: 0;
}
.plan-info {
flex: 1;
}
.plan-name {
display: block;
font-size: 28rpx;
font-weight: 700;
color: #333;
margin-bottom: 6rpx;
}
.plan-desc {
display: block;
font-size: 22rpx;
color: #999;
}
.plan-price {
text-align: right;
flex-shrink: 0;
}
.price-amount {
font-size: 40rpx;
font-weight: 800;
color: #D97706;
}
.price-amount.single {
font-size: 32rpx;
color: #333;
}
.price-symbol {
font-size: 24rpx;
}
.price-original {
display: block;
font-size: 20rpx;
color: #CCC;
text-decoration: line-through;
}
.pay-summary {
display: flex;
align-items: baseline;
margin-bottom: 12rpx;
padding: 0 8rpx;
}
.pay-label {
font-size: 26rpx;
font-weight: 700;
color: #666;
}
.pay-amount {
font-size: 36rpx;
font-weight: 800;
color: #D97706;
margin-left: 4rpx;
}
.pay-method {
display: flex;
align-items: center;
justify-content: flex-end;
margin-bottom: 20rpx;
padding: 0 8rpx;
}
.pay-method-text {
font-size: 22rpx;
color: #999;
}
.wechat-check {
font-size: 18rpx;
background: #07C160;
color: #FFF;
padding: 2rpx 8rpx;
border-radius: 4rpx;
margin-left: 8rpx;
}