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);
}