前端(uni-app · Vue3)
- 设计系统:uni.scss 品牌令牌(花红 #E8604C / 蜜橙 #F5A65B / 暖米白 #FFF9F5),
App.vue 全局原子类(卡片/按钮/胶囊/单元格/空态/进度)
- 页面:登录、首页(聚合)、周期(花瓣日历+当日记录+预测+概览)、情绪(记录+趋势+日记+疗愈)、
我的(资料/会员/隐私/提醒)、健康报告、会员套餐、内容详情
- 组件:hq-tabbar(自定义底栏)、bloom-calendar(阶段日历)
- 基础层:utils/{config,request,store,api,date}.js(统一 token、401 处理、接口集中定义)
后端(FastAdmin / ThinkPHP 5.0)
- 新增 api 控制器:Home / Cycle / Mood / Content / Member / Setting,User 增补 detail、updateprofile
- 新增服务层:CycleService(四阶段计算、预测、月历、汇总)、MemberService(权益判定)
- 新增表:fa_cycle / fa_cycle_log / fa_symptom_tag / fa_mood_log / fa_content /
fa_content_category / fa_member_plan / fa_member_order / fa_user_setting
- 初始化数据:20 个症状标签、28 条内容(心语/科普/知识/食谱/运动/睡眠/痛经/冥想)、3 档会员
验证(全部通过)
- 接口全链路 48 项断言(登录→周期→首页→情绪→内容→会员→设置→资料)
- 前后端契约静态校验 24 个接口 0 偏差
- Vue SFC 编译 11/11、SCSS 编译 10/10
- 验证脚本见 scripts/verify/,报告见 docs/04-前端设计系统与联调验证.md
156 行
5.2 KiB
PHP
156 行
5.2 KiB
PHP
<?php
|
||
|
||
namespace app\api\controller;
|
||
|
||
use app\common\controller\Api;
|
||
use app\common\service\CycleService;
|
||
use app\common\service\MemberService;
|
||
use think\Db;
|
||
|
||
/**
|
||
* 内容接口(M2 心语/科普 + M5 健康建议 + 冥想音频)
|
||
*/
|
||
class Content extends Api
|
||
{
|
||
protected $noNeedLogin = [];
|
||
protected $noNeedRight = '*';
|
||
|
||
/**
|
||
* 内容列表
|
||
* GET type=jijing|mood|knowledge|advice|meditation phase=menstrual access=free
|
||
*/
|
||
public function index()
|
||
{
|
||
$type = $this->request->param('type', '');
|
||
$phase = $this->request->param('phase', '');
|
||
$categoryId = (int)$this->request->param('category_id', 0);
|
||
$page = max(1, (int)$this->request->param('page', 1));
|
||
$limit = min(50, max(1, (int)$this->request->param('limit', 10)));
|
||
|
||
// 未显式传阶段时,按当前周期阶段智能匹配
|
||
if (!$phase) {
|
||
$current = CycleService::current($this->auth->id);
|
||
$phase = $current['phase'] ?: '';
|
||
}
|
||
|
||
$query = Db::name('content')
|
||
->where('status', 'normal')
|
||
->where('verify', 'passed');
|
||
|
||
if ($type) {
|
||
$query->where('type', $type);
|
||
}
|
||
if ($categoryId) {
|
||
$query->where('category_id', $categoryId);
|
||
}
|
||
|
||
$all = $query->order('weigh', 'desc')->select();
|
||
|
||
// 阶段匹配优先:先取命中当前阶段(含 all)的内容,过少时回退全量,
|
||
// 避免某些阶段(如排卵期)内容过少导致列表空置
|
||
if ($phase) {
|
||
$matched = array_values(array_filter($all, function ($item) use ($phase) {
|
||
$phases = array_filter(explode(',', $item['phase']));
|
||
return in_array('all', $phases) || in_array($phase, $phases);
|
||
}));
|
||
if (count($matched) >= 3 || count($matched) === count($all)) {
|
||
$all = $matched;
|
||
} else {
|
||
$rest = array_values(array_filter($all, function ($item) use ($matched) {
|
||
return !in_array($item['id'], array_column($matched, 'id'));
|
||
}));
|
||
$all = array_merge($matched, $rest);
|
||
}
|
||
}
|
||
|
||
$total = count($all);
|
||
$list = array_slice($all, ($page - 1) * $limit, $limit);
|
||
|
||
$member = MemberService::status($this->auth->id);
|
||
$list = array_map(function ($item) use ($member) {
|
||
return [
|
||
'id' => (int)$item['id'],
|
||
'type' => $item['type'],
|
||
'title' => $item['title'],
|
||
'summary' => $item['summary'],
|
||
'emoji' => $item['cover_emoji'],
|
||
'media_type' => $item['media_type'],
|
||
'duration' => (int)$item['duration'],
|
||
'duration_text' => $item['duration'] ? floor($item['duration'] / 60) . ' 分钟' : '',
|
||
'access' => $item['access'],
|
||
'locked' => $item['access'] === 'member' && !$member['is_member'],
|
||
];
|
||
}, $list);
|
||
|
||
$this->success('', [
|
||
'list' => $list,
|
||
'total' => $total,
|
||
'page' => $page,
|
||
'limit' => $limit,
|
||
'has_more' => $page * $limit < $total,
|
||
'phase' => $phase,
|
||
'category' => $this->categoryList($type),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 内容详情
|
||
* GET id=1
|
||
*/
|
||
public function detail()
|
||
{
|
||
$id = (int)$this->request->param('id', 0);
|
||
$item = Db::name('content')->where('id', $id)->where('status', 'normal')->find();
|
||
if (!$item) {
|
||
$this->error('内容不存在');
|
||
}
|
||
|
||
$member = MemberService::status($this->auth->id);
|
||
$locked = $item['access'] === 'member' && !$member['is_member'];
|
||
|
||
$this->success('', [
|
||
'id' => (int)$item['id'],
|
||
'type' => $item['type'],
|
||
'title' => $item['title'],
|
||
'summary' => $item['summary'],
|
||
'body' => $locked ? mb_substr($item['body'], 0, 80) . '…' : $item['body'],
|
||
'emoji' => $item['cover_emoji'],
|
||
'media_type' => $item['media_type'],
|
||
'duration' => (int)$item['duration'],
|
||
'access' => $item['access'],
|
||
'locked' => $locked,
|
||
'lock_tip' => $locked ? '该内容为会员专享,开通会员即可查看' : '',
|
||
'disclaimer' => $item['type'] === 'advice' || $item['type'] === 'knowledge'
|
||
? '以上内容仅供参考,不能替代医疗诊断。' : '',
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 分类
|
||
*/
|
||
public function categories()
|
||
{
|
||
$type = $this->request->param('type', '');
|
||
$query = Db::name('content_category')->where('status', 'normal');
|
||
if ($type) {
|
||
$query->where('type', $type);
|
||
}
|
||
$list = $query->order('weigh', 'desc')->select();
|
||
$this->success('', ['list' => $list]);
|
||
}
|
||
|
||
protected function categoryList($type)
|
||
{
|
||
if (!$type) {
|
||
return [];
|
||
}
|
||
$list = Db::name('content_category')
|
||
->where('type', $type)
|
||
->where('status', 'normal')
|
||
->order('weigh', 'desc')
|
||
->field('id,name')
|
||
->select();
|
||
return $list;
|
||
}
|
||
}
|