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
+216
View File
@@ -0,0 +1,216 @@
/**
* 早安电台 — 后端 API 接口封装
* 统一调用 request.js,对外暴露业务方法
*/
const { get, post } = require('./request')
// ======================== 登录相关 ========================
/** 小程序登录 (wx.login code → token + user) */
function miniLogin(code) {
return get('/auth/miniLogin', { code })
}
/** 获取位置信息 */
function getLocation(longitude, latitude) {
return get('/auth/getLocation', { longitude, latitude })
}
/** 获取天气 */
function getWeather(adcode) {
return get('/auth/getWeather', { adcode })
}
/** 获取手机号 */
function getPhone(code, openId) {
return get('/auth/getPhone', { code, openId })
}
// ======================== 分类管理 ========================
/** 获取分类列表(全量) */
function getCategoryList() {
return get('/radio/category/list')
}
/** 获取分类树 */
function getCategoryTree() {
return get('/radio/category/tree')
}
// ======================== 频道管理 ========================
/** 获取频道列表(分页) */
function getChannelList(params) {
return post('/radio/channel/list', {
current: params.current || 1,
pageSize: params.pageSize || 50,
categoryId: params.categoryId || '',
status: 1 // 仅上架
})
}
/** 获取免费频道列表 */
function getFreeChannelList(params) {
return post('/radio/channel/freeList', {
current: (params && params.current) || 1,
pageSize: (params && params.pageSize) || 20,
keyword: (params && params.keyword) || ''
})
}
/** 获取频道详情 */
function getChannelDetail(id) {
return get('/radio/channel/detail', { id })
}
// ======================== 节目管理 ========================
/** 获取节目列表(分页,需传 channelId) */
function getProgramList(params) {
return post('/radio/program/list', {
channelId: params.channelId,
current: params.current || 1,
pageSize: params.pageSize || 50,
status: 1
})
}
/** 获取节目详情 */
function getProgramDetail(id) {
return get('/radio/program/detail', { id })
}
// ======================== 订阅管理 ========================
/** 获取我的订阅列表 */
function getSubscriptionList(params) {
return post('/radio/subscription/list', {
current: (params && params.current) || 1,
pageSize: (params && params.pageSize) || 50
})
}
/** 检查是否可以订阅 */
function canSubscribe(channelId) {
return post('/radio/subscription/can-subscribe', { channelId })
}
/** 订阅频道 */
function subscribe(channelId) {
return post('/radio/subscription/subscribe', { channelId })
}
/** 退订频道 */
function unsubscribe(channelId) {
return post('/radio/subscription/unsubscribe', { channelId })
}
// ======================== 收听历史 ========================
/** 添加收听历史 */
function addHistory(params) {
return post('/radio/history/add', {
programId: params.programId,
progress: params.progress || 0,
duration: params.duration || 0
})
}
/** 获取收听历史列表 */
function getHistoryList(params) {
return post('/radio/history/list', {
current: (params && params.current) || 1,
pageSize: (params && params.pageSize) || 20
})
}
// ======================== 收藏 ========================
/** 添加收藏 */
function addFavorite(programId) {
return post('/radio/favorite/add', { programId })
}
/** 取消收藏 */
function removeFavorite(programId) {
return post('/radio/favorite/remove', { programId })
}
/** 获取收藏列表 */
function getFavoriteList(params) {
return post('/radio/favorite/list', {
current: (params && params.current) || 1,
pageSize: (params && params.pageSize) || 20
})
}
// ======================== 点赞 / 评论 ========================
/** 切换点赞 */
function toggleLike(programId) {
return post('/radio/like/toggle', { programId })
}
/** 获取当前登录用户 */
function getUserInfo() {
return get('/user/info')
}
/** 付费订阅频道(传入方案 monthly/quarterly/annual 和价格) */
function subscribeChannel(params) {
return post('/radio/subscription/pay', params)
}
/**
* 主动查询微信支付状态
* @param {string} outTradeNo 商户订单号(上游返回)
* @returns {Promise<boolean>} true = 支付成功
*/
function queryPayStatus(outTradeNo) {
return get('/pay/query', { outTradeNo })
.then(function (res) {
// 后端返回 bool 或 { data: bool }
if (typeof res === 'boolean') return res
if (typeof res.data === 'boolean') return res.data
return res.code === 200 && !!res.data
})
}
/**
* 频道解锁(唇起微信支付骄支付单)
* @param {string} channelId
* @param {string} type '1'=包月 '2'=包季 '3'=包年
*/
function unlockChannel(channelId, type) {
return post('/radio/subscription/unlock', { channelId, type })
}
module.exports = {
miniLogin,
getLocation,
getWeather,
getPhone,
getCategoryList,
getCategoryTree,
getChannelList,
getFreeChannelList,
getChannelDetail,
getProgramList,
getProgramDetail,
getSubscriptionList,
canSubscribe,
subscribe,
unsubscribe,
addHistory,
getHistoryList,
addFavorite,
removeFavorite,
getFavoriteList,
toggleLike,
getUserInfo,
subscribeChannel,
unlockChannel,
queryPayStatus
}
+220
View File
@@ -0,0 +1,220 @@
/**
* 早安电台 — 音频管理器
* 封装 wx.getBackgroundAudioManager(),管理全局背景播放
* 确保小程序切后台后音频不断
* 播放时自动上报收听历史
*/
const api = require('./api')
let bgAudioManager = null
let appInstance = null
let _switching = false // 切换音频时的锁,防止 onStop 事件干扰
/**
* 初始化音频管理器
* @param {Object} app - App 实例
*/
function init(app) {
appInstance = app
bgAudioManager = wx.getBackgroundAudioManager()
// 绑定音频事件
bgAudioManager.onPlay(() => {
_switching = false // 新音频开始播放,释放锁
updatePlayState(true)
})
bgAudioManager.onPause(() => {
updatePlayState(false)
// 暂停时上报进度
reportHistory()
})
bgAudioManager.onStop(() => {
// 如果正在切换音频,不处理 onStop(新的 src 设置会触发旧的 onStop
if (_switching) return
reportHistory()
updatePlayState(false)
appInstance.globalData.currentTime = 0
appInstance.emit('playerStateChange', getState())
})
bgAudioManager.onEnded(() => {
reportHistory()
updatePlayState(false)
appInstance.globalData.currentTime = appInstance.globalData.duration
appInstance.emit('playerStateChange', getState())
})
// 进度更新回调
bgAudioManager.onTimeUpdate(() => {
if (_switching) return
const ct = Math.floor(bgAudioManager.currentTime || 0)
const dur = Math.floor(bgAudioManager.duration || 0)
appInstance.globalData.currentTime = ct
if (dur > 0) {
appInstance.globalData.duration = dur
}
appInstance.emit('timeUpdate', { currentTime: ct, duration: dur })
})
bgAudioManager.onError((err) => {
console.error('[AudioManager] 播放出错:', err)
_switching = false
updatePlayState(false)
})
bgAudioManager.onWaiting(() => {
console.log('[AudioManager] 缓冲中...')
})
}
/**
* 更新播放状态并通知
*/
function updatePlayState(isPlaying) {
appInstance.globalData.isPlaying = isPlaying
appInstance.emit('playerStateChange', getState())
}
/**
* 获取当前播放器状态
*/
function getState() {
return {
activeContent: appInstance.globalData.activeContent,
isPlaying: appInstance.globalData.isPlaying,
currentTime: appInstance.globalData.currentTime,
duration: appInstance.globalData.duration,
playbackRate: appInstance.globalData.playbackRate
}
}
/**
* 获取音频 URL
*/
function getAudioUrl(content) {
if (!content) return ''
if (content.audio && content.audio.url) return content.audio.url
if (content.audioUrl) return content.audioUrl
return ''
}
/**
* 上报收听历史到后端
*/
function reportHistory() {
const content = appInstance.globalData.activeContent
if (!content || !content.id) return
api.addHistory({
programId: content.id,
progress: appInstance.globalData.currentTime || 0,
duration: appInstance.globalData.duration || content.duration || 0
}).catch(function (err) {
console.warn('[AudioManager] 上报历史失败:', err)
})
}
/**
* 播放指定内容
* @param {Object} content - 音频内容对象 { id, title, duration, audioUrl/audio.url, ... }
*/
function playContent(content) {
if (!bgAudioManager || !content) return
var audioUrl = getAudioUrl(content)
if (!audioUrl) {
console.error('[AudioManager] 音频内容缺少 audioUrl:', content)
wx.showToast({ title: '音频地址无效', icon: 'none' })
return
}
// 如果当前有播放内容,先上报历史
var oldContent = appInstance.globalData.activeContent
if (oldContent && oldContent.id && oldContent.id !== content.id) {
reportHistory()
}
// 标记正在切换,防止旧音频的 onStop 干扰
_switching = true
appInstance.globalData.activeContent = content
appInstance.globalData.currentTime = 0
appInstance.globalData.duration = content.duration || 0
// 设置背景音频属性(必须设置 title,否则 iOS 上不允许播放)
bgAudioManager.title = content.title || '早安电台'
bgAudioManager.singer = '早安电台'
bgAudioManager.epname = content.title || '早安电台'
bgAudioManager.coverImgUrl = (content.cover && content.cover.url) || content.coverUrl || ''
// 设置音频源(这会触发旧音频的 onStop,然后自动开始播放新音频)
bgAudioManager.src = audioUrl
appInstance.emit('playerStateChange', getState())
}
/**
* 切换播放/暂停
* 关键:如果音频已结束或 src 被回收,需要重新设置 src 才能恢复播放
*/
function togglePlay() {
if (!bgAudioManager || !appInstance.globalData.activeContent) return
if (appInstance.globalData.isPlaying) {
bgAudioManager.pause()
} else {
// 检查 bgAudioManager 是否还有有效的 src
// 音频结束/被系统回收后,src 会变为空,此时 play() 无效
// 需要重新设置 src 来恢复播放
var currentSrc = ''
try {
currentSrc = bgAudioManager.src
} catch (e) {
currentSrc = ''
}
if (!currentSrc) {
// src 已被清空(音频结束、系统回收等),重新播放
playContent(appInstance.globalData.activeContent)
} else {
bgAudioManager.play()
}
}
}
/**
* 跳转到指定时间(秒)
*/
function seekTo(time) {
if (!bgAudioManager) return
bgAudioManager.seek(time)
appInstance.globalData.currentTime = time
appInstance.emit('timeUpdate', {
currentTime: time,
duration: appInstance.globalData.duration
})
}
/**
* 设置播放速率
* @param {number} rate - 0.5 / 0.75 / 1.0 / 1.25 / 1.5 / 2.0
*/
function setPlaybackRate(rate) {
if (!bgAudioManager) return
bgAudioManager.playbackRate = rate
appInstance.globalData.playbackRate = rate
appInstance.emit('playerStateChange', getState())
}
module.exports = {
init,
getState,
playContent,
togglePlay,
seekTo,
setPlaybackRate
}
+188
View File
@@ -0,0 +1,188 @@
/**
* 早安电台 — 模拟数据
* 12个垂直频道定义 + 动态生成音频内容
*/
/**
* 频道领域定义
* icon: 使用 emoji 或 TDesign icon 名称作为图标标识
* bgColor: 频道品牌色(用于渐变背景)
* bgColorLight: 浅色版本(用于标签、背景点缀)
*/
const DOMAINS = [
{
id: 'career',
name: '职场成长',
description: '职场沟通、管理技巧、晋升干货',
tag: '每日3个职场技巧',
icon: '💼',
bgColor: '#3B82F6',
bgColorEnd: '#2563EB',
bgColorLight: 'rgba(59,130,246,0.1)'
},
{
id: 'tech',
name: '程序员早报',
description: '技术资讯、开源动态、编程技巧',
tag: '每日3条技术简讯',
icon: '💻',
bgColor: '#374151',
bgColorEnd: '#111827',
bgColorLight: 'rgba(55,65,81,0.1)'
},
{
id: 'ecommerce',
name: '电商/跨境资讯',
description: '平台规则、选品技巧、流量玩法',
tag: '电商人每日必听',
icon: '🛒',
bgColor: '#F97316',
bgColorEnd: '#EA580C',
bgColorLight: 'rgba(249,115,22,0.1)'
},
{
id: 'finance',
name: '财经轻资讯',
description: '股市简讯、理财知识、行业风口',
tag: '轻松懂财经',
icon: '📈',
bgColor: '#EF4444',
bgColorEnd: '#B91C1C',
bgColorLight: 'rgba(239,68,68,0.1)'
},
{
id: 'health',
name: '健康养生',
description: '晨间养生、饮食建议、作息调理',
tag: '每日养生小知识',
icon: '🌿',
bgColor: '#22C55E',
bgColorEnd: '#16A34A',
bgColorLight: 'rgba(34,197,94,0.1)'
},
{
id: 'reading',
name: '读书文摘',
description: '书籍摘要、名言解读、阅读感悟',
tag: '每日一篇读书感悟',
icon: '📖',
bgColor: '#D97706',
bgColorEnd: '#92400E',
bgColorLight: 'rgba(217,119,6,0.1)'
},
{
id: 'parenting',
name: '育儿晨读',
description: '育儿技巧、亲子沟通、启蒙知识',
tag: '宝妈每日育儿指南',
icon: '👶',
bgColor: '#EC4899',
bgColorEnd: '#DB2777',
bgColorLight: 'rgba(236,72,153,0.1)'
},
{
id: 'psychology',
name: '心理学小知识',
description: '情绪管理、人际关系、心理常识',
tag: '读懂自己与他人',
icon: '🧠',
bgColor: '#8B5CF6',
bgColorEnd: '#7C3AED',
bgColorLight: 'rgba(139,92,246,0.1)'
},
{
id: 'english',
name: '职场英语',
description: '常用句型、单词积累、口语技巧',
tag: '每日3句职场英语',
icon: '🌐',
bgColor: '#6366F1',
bgColorEnd: '#4F46E5',
bgColorLight: 'rgba(99,102,241,0.1)'
},
{
id: 'startup',
name: '创业干货',
description: '创业案例、融资动态、运营技巧',
tag: '创业者每日灵感',
icon: '🚀',
bgColor: '#F43F5E',
bgColorEnd: '#E11D48',
bgColorLight: 'rgba(244,63,94,0.1)'
},
{
id: 'design',
name: '设计灵感',
description: '设计趋势、作品赏析、技巧分享',
tag: '每日设计灵感',
icon: '🎨',
bgColor: '#14B8A6',
bgColorEnd: '#0D9488',
bgColorLight: 'rgba(20,184,166,0.1)'
},
{
id: 'speaking',
name: '职场口才',
description: '沟通技巧、演讲方法、表达逻辑',
tag: '提升口才每一天',
icon: '🎙️',
bgColor: '#06B6D4',
bgColorEnd: '#0891B2',
bgColorLight: 'rgba(6,182,212,0.1)'
}
]
/**
* 动态生成模拟音频内容
* 为每个频道生成最近 5 天的音频记录
*/
function generateMockContent() {
const contents = []
const today = new Date()
DOMAINS.forEach(function (domain) {
for (var i = 0; i < 5; i++) {
var d = new Date(today)
d.setDate(today.getDate() - i)
var year = d.getFullYear()
var month = String(d.getMonth() + 1).padStart(2, '0')
var day = String(d.getDate()).padStart(2, '0')
var dateStr = year + '-' + month + '-' + day
contents.push({
id: domain.id + '-' + dateStr,
domainId: domain.id,
date: dateStr,
title: i === 0
? '[今日更新] ' + domain.name + '晨间电台'
: dateStr + ' ' + domain.name + '往期回顾',
duration: 60 + Math.floor(Math.random() * 60), // 60~120 秒
content: '早上好,今天是' + (d.getMonth() + 1) + '月' + d.getDate() + '日。' +
'欢迎收听' + domain.name + '频道,这里是为您精选的晨间干货内容...\n' +
'(这里是模拟生成的1分钟左右音频文案内容,包含3个核心知识点和一个金句作为结尾。祝您有美好的一天!)',
audioUrl: '' // 实际项目中由后端提供
})
}
})
return contents
}
const MOCK_AUDIO_CONTENTS = generateMockContent()
/**
* 频道分类映射
*/
const DOMAIN_CATEGORIES = {
'全部': null, // null 表示显示所有
'职场': ['career', 'english', 'speaking', 'startup'],
'科技': ['tech', 'design'],
'生活': ['health', 'parenting', 'reading', 'psychology', 'ecommerce', 'finance']
}
module.exports = {
DOMAINS,
MOCK_AUDIO_CONTENTS,
DOMAIN_CATEGORIES,
generateMockContent
}
+85
View File
@@ -0,0 +1,85 @@
/**
* 早安电台 — API 请求封装
* 支持 Promise、BaseURL 配置、自动附带 Token
*/
// 接口基础地址(预留占位符,对接后端时替换)
const API_BASE_URL = 'https://radio.sundynix.cn/api'
//const API_BASE_URL = 'http://192.168.0.184:8888'
/**
* 获取本地存储的 token
*/
function getToken() {
return wx.getStorageSync('token') || ''
}
/**
* 通用请求方法
* @param {Object} options
* @param {string} options.url - 接口路径(不含 BaseURL
* @param {string} [options.method='GET'] - 请求方法
* @param {Object} [options.data] - 请求参数
* @param {Object} [options.header] - 自定义请求头
* @returns {Promise}
*/
function request(options) {
return new Promise((resolve, reject) => {
const token = getToken()
wx.request({
url: `${API_BASE_URL}${options.url}`,
method: options.method || 'GET',
data: options.data || {},
header: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
...options.header
},
success(res) {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(res.data)
} else if (res.statusCode === 401) {
// Token 过期或未登录,跳转到登录
wx.removeStorageSync('token')
wx.redirectTo({ url: '/pages/splash/index' })
reject(new Error('未授权,请重新登录'))
} else {
reject(new Error(res.data.message || `请求失败: ${res.statusCode}`))
}
},
fail(err) {
reject(new Error(err.errMsg || '网络请求失败'))
}
})
})
}
/**
* 快捷方法
*/
function get(url, data) {
return request({ url, method: 'GET', data })
}
function post(url, data) {
return request({ url, method: 'POST', data })
}
function put(url, data) {
return request({ url, method: 'PUT', data })
}
function del(url, data) {
return request({ url, method: 'DELETE', data })
}
module.exports = {
API_BASE_URL,
request,
get,
post,
put,
del
}
+122
View File
@@ -0,0 +1,122 @@
/**
* 早安电台 — 通用工具函数
*/
/**
* 秒数格式化为 mm:ss
* @param {number} seconds - 总秒数
* @returns {string} - 如 "01:30"
*/
function formatTime(seconds) {
seconds = Math.floor(seconds || 0)
var min = Math.floor(seconds / 60)
var sec = seconds % 60
return padZero(min) + ':' + padZero(sec)
}
/**
* 补零
*/
function padZero(num) {
return num < 10 ? '0' + num : '' + num
}
/**
* 日期格式化
* @param {Date|string} date
* @param {string} [format='YYYY-MM-DD']
* @returns {string}
*/
function formatDate(date, format) {
if (typeof date === 'string') {
date = new Date(date.replace(/-/g, '/'))
}
format = format || 'YYYY-MM-DD'
var year = date.getFullYear()
var month = padZero(date.getMonth() + 1)
var day = padZero(date.getDate())
return format
.replace('YYYY', year)
.replace('MM', month)
.replace('DD', day)
}
/**
* 获取星期几
* @param {Date} [date]
* @returns {string} 周一~周日
*/
function getWeekDay(date) {
date = date || new Date()
var days = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
return days[date.getDay()]
}
/**
* 获取今天的日期字符串 YYYY-MM-DD
*/
function getTodayStr() {
return formatDate(new Date())
}
/**
* 获取友好的日期显示
* @param {string} dateStr - YYYY-MM-DD
* @returns {string} 如 "今天"、"昨天"、"3月1日"
*/
function getFriendlyDate(dateStr) {
var today = getTodayStr()
if (dateStr === today) return '今天'
var yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
if (dateStr === formatDate(yesterday)) return '昨天'
var d = new Date(dateStr.replace(/-/g, '/'))
return (d.getMonth() + 1) + '月' + d.getDate() + '日'
}
/**
* 日期字符串替换 - 为 .
*/
function dateToDot(dateStr) {
return (dateStr || '').replace(/-/g, '.')
}
/**
* 获取日期显示 X月X日
*/
function getDateDisplay(date) {
date = date || new Date()
return (date.getMonth() + 1) + '月' + date.getDate() + '日'
}
/**
* 节流函数
*/
function throttle(fn, delay) {
var timer = null
return function () {
if (timer) return
var args = arguments
var context = this
timer = setTimeout(function () {
fn.apply(context, args)
timer = null
}, delay)
}
}
module.exports = {
formatTime,
padZero,
formatDate,
getWeekDay,
getTodayStr,
getFriendlyDate,
dateToDot,
getDateDisplay,
throttle
}