前端(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
343 行
11 KiB
PHP
343 行
11 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;
|
||
use think\Validate;
|
||
|
||
/**
|
||
* 周期管理接口(M1)
|
||
*/
|
||
class Cycle extends Api
|
||
{
|
||
protected $noNeedLogin = [];
|
||
protected $noNeedRight = '*';
|
||
|
||
/**
|
||
* 月历视图
|
||
* GET month=2026-11
|
||
*/
|
||
public function index()
|
||
{
|
||
$month = $this->request->param('month', date('Y-m'));
|
||
if (!Validate::regex($month, '/^\d{4}-\d{2}$/')) {
|
||
$month = date('Y-m');
|
||
}
|
||
$data = CycleService::calendar($this->auth->id, $month);
|
||
$this->success('', $data);
|
||
}
|
||
|
||
/**
|
||
* 今日周期状态
|
||
*/
|
||
public function current()
|
||
{
|
||
$this->success('', CycleService::current($this->auth->id));
|
||
}
|
||
|
||
/**
|
||
* 未来 3 个月预测
|
||
*/
|
||
public function predict()
|
||
{
|
||
$this->success('', CycleService::predict($this->auth->id, 3));
|
||
}
|
||
|
||
/**
|
||
* 记录 / 修改一次经期
|
||
* POST start_date=2026-11-01 end_date=2026-11-05
|
||
*/
|
||
public function save()
|
||
{
|
||
$userId = $this->auth->id;
|
||
$startDate = $this->request->post('start_date', '');
|
||
$endDate = $this->request->post('end_date', '');
|
||
|
||
if (!$startDate || !strtotime($startDate)) {
|
||
$this->error('请选择经期开始日期');
|
||
}
|
||
if ($endDate && strtotime($endDate) < strtotime($startDate)) {
|
||
$this->error('结束日期不能早于开始日期');
|
||
}
|
||
if (strtotime($startDate) > time() + 86400) {
|
||
$this->error('开始日期不能晚于今天');
|
||
}
|
||
|
||
$periodLength = $endDate ? (int)((strtotime($endDate) - strtotime($startDate)) / 86400) + 1 : 5;
|
||
if ($periodLength > 15) {
|
||
$this->error('经期天数超出合理范围');
|
||
}
|
||
|
||
$exist = Db::name('cycle')
|
||
->where('user_id', $userId)
|
||
->where('start_date', $startDate)
|
||
->whereNull('deletetime')
|
||
->find();
|
||
|
||
$params = CycleService::params($userId);
|
||
$data = [
|
||
'user_id' => $userId,
|
||
'start_date' => $startDate,
|
||
'end_date' => $endDate ?: null,
|
||
'period_length' => $periodLength,
|
||
'cycle_length' => $params['cycle_length'],
|
||
'ovulation_date' => date('Y-m-d', strtotime($startDate) + ($params['cycle_length'] - 14) * 86400),
|
||
'updatetime' => time(),
|
||
];
|
||
|
||
if ($exist) {
|
||
Db::name('cycle')->where('id', $exist['id'])->update($data);
|
||
$id = $exist['id'];
|
||
} else {
|
||
$data['createtime'] = time();
|
||
$id = Db::name('cycle')->insertGetId($data);
|
||
}
|
||
|
||
// 重新计算历史周期的平均长度(新周期写入后再回填)
|
||
$cycles = CycleService::cycles($userId, 6);
|
||
if (count($cycles) >= 2) {
|
||
$days = (int)((strtotime($cycles[0]['start_date']) - strtotime($cycles[1]['start_date'])) / 86400);
|
||
if ($days >= 18 && $days <= 60) {
|
||
Db::name('cycle')->where('id', $cycles[0]['id'])->update(['cycle_length' => $days]);
|
||
}
|
||
}
|
||
|
||
$this->success('记录成功', [
|
||
'id' => (int)$id,
|
||
'cycle' => CycleService::current($userId),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 保存每日症状记录(同日覆盖)
|
||
* POST log_date, flow, pain_level, symptom_ids, mood, sleep_hours, sex, note
|
||
*/
|
||
public function log()
|
||
{
|
||
$userId = $this->auth->id;
|
||
$logDate = $this->request->post('log_date', date('Y-m-d'));
|
||
if (!strtotime($logDate)) {
|
||
$this->error('日期格式不正确');
|
||
}
|
||
if (strtotime($logDate) > strtotime('+1 day')) {
|
||
$this->error('不能记录未来日期');
|
||
}
|
||
|
||
$data = [
|
||
'user_id' => $userId,
|
||
'log_date' => $logDate,
|
||
'flow' => min(3, max(0, (int)$this->request->post('flow', 0))),
|
||
'pain_level' => min(5, max(0, (int)$this->request->post('pain_level', 0))),
|
||
'symptom_ids' => (string)$this->request->post('symptom_ids', ''),
|
||
'mood' => min(2, max(-2, (int)$this->request->post('mood', 0))),
|
||
'sex' => min(2, max(0, (int)$this->request->post('sex', 0))),
|
||
'note' => mb_substr((string)$this->request->post('note', ''), 0, 500),
|
||
'updatetime' => time(),
|
||
];
|
||
|
||
$sleep = $this->request->post('sleep_hours', '');
|
||
if ($sleep !== '' && is_numeric($sleep)) {
|
||
$data['sleep_hours'] = min(24, max(0, round((float)$sleep, 1)));
|
||
}
|
||
|
||
$exist = Db::name('cycle_log')
|
||
->where('user_id', $userId)
|
||
->where('log_date', $logDate)
|
||
->whereNull('deletetime')
|
||
->find();
|
||
|
||
if ($exist) {
|
||
Db::name('cycle_log')->where('id', $exist['id'])->update($data);
|
||
$id = $exist['id'];
|
||
} else {
|
||
$data['createtime'] = time();
|
||
$id = Db::name('cycle_log')->insertGetId($data);
|
||
}
|
||
|
||
$this->success('已保存', ['id' => (int)$id]);
|
||
}
|
||
|
||
/**
|
||
* 某天的记录详情
|
||
* GET date=2026-11-02
|
||
*/
|
||
public function loginfo()
|
||
{
|
||
$date = $this->request->param('date', date('Y-m-d'));
|
||
$log = Db::name('cycle_log')
|
||
->where('user_id', $this->auth->id)
|
||
->where('log_date', $date)
|
||
->whereNull('deletetime')
|
||
->find();
|
||
|
||
if ($log) {
|
||
$log['symptom_ids'] = $log['symptom_ids'] === '' ? [] : array_map('intval', explode(',', $log['symptom_ids']));
|
||
}
|
||
|
||
$this->success('', [
|
||
'date' => $date,
|
||
'log' => $log ?: null,
|
||
'tags' => $this->tagsData(),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 症状标签(记录面板用)
|
||
*/
|
||
public function tags()
|
||
{
|
||
$this->success('', ['list' => $this->tagsData()]);
|
||
}
|
||
|
||
protected function tagsData()
|
||
{
|
||
$list = Db::name('symptom_tag')
|
||
->where('status', 'normal')
|
||
->where(function ($query) {
|
||
$query->where('is_preset', 1)->whereOr('user_id', $this->auth->id);
|
||
})
|
||
->order('weigh', 'desc')
|
||
->select();
|
||
|
||
return array_map(function ($item) {
|
||
return [
|
||
'id' => (int)$item['id'],
|
||
'name' => $item['name'],
|
||
'icon' => $item['icon'],
|
||
'category' => $item['category'],
|
||
];
|
||
}, $list);
|
||
}
|
||
|
||
/**
|
||
* 周期汇总
|
||
*/
|
||
public function summary()
|
||
{
|
||
$this->success('', CycleService::summary($this->auth->id));
|
||
}
|
||
|
||
/**
|
||
* 健康分析报告(基础免费,深度为会员权益)
|
||
* GET type=month|quarter|year month=2026-11
|
||
*/
|
||
public function report()
|
||
{
|
||
$userId = $this->auth->id;
|
||
$type = $this->request->param('type', 'month');
|
||
$month = $this->request->param('month', date('Y-m'));
|
||
|
||
$start = date('Y-m-01', strtotime($month . '-01'));
|
||
$end = date('Y-m-t', strtotime($month . '-01'));
|
||
|
||
$logs = Db::name('cycle_log')
|
||
->where('user_id', $userId)
|
||
->whereNull('deletetime')
|
||
->whereBetween('log_date', [$start, $end])
|
||
->select();
|
||
|
||
$painList = [];
|
||
$flowList = [];
|
||
$symptomCount = [];
|
||
$sleepTotal = 0;
|
||
$sleepCount = 0;
|
||
foreach ($logs as $log) {
|
||
if ($log['pain_level'] > 0) {
|
||
$painList[] = (int)$log['pain_level'];
|
||
}
|
||
if ($log['flow'] > 0) {
|
||
$flowList[] = (int)$log['flow'];
|
||
}
|
||
foreach (array_filter(explode(',', $log['symptom_ids'])) as $sid) {
|
||
$symptomCount[$sid] = isset($symptomCount[$sid]) ? $symptomCount[$sid] + 1 : 1;
|
||
}
|
||
if ($log['sleep_hours'] !== null && $log['sleep_hours'] > 0) {
|
||
$sleepTotal += (float)$log['sleep_hours'];
|
||
$sleepCount++;
|
||
}
|
||
}
|
||
|
||
arsort($symptomCount);
|
||
$topSymptomIds = array_slice(array_keys($symptomCount), 0, 5);
|
||
$tagNames = Db::name('symptom_tag')->whereIn('id', $topSymptomIds ?: [0])->column('name', 'id');
|
||
$topSymptoms = [];
|
||
foreach ($topSymptomIds as $sid) {
|
||
if (isset($tagNames[$sid])) {
|
||
$topSymptoms[] = ['name' => $tagNames[$sid], 'count' => $symptomCount[$sid]];
|
||
}
|
||
}
|
||
|
||
$moodLogs = Db::name('mood_log')
|
||
->where('user_id', $userId)
|
||
->whereNull('deletetime')
|
||
->whereBetween('log_date', [$start, $end])
|
||
->select();
|
||
$moodCount = [];
|
||
foreach ($moodLogs as $m) {
|
||
$moodCount[$m['mood']] = isset($moodCount[$m['mood']]) ? $moodCount[$m['mood']] + 1 : 1;
|
||
}
|
||
|
||
$summary = CycleService::summary($userId);
|
||
$deep = MemberService::can($userId, 'report.deep');
|
||
|
||
$this->success('', [
|
||
'type' => $type,
|
||
'month' => $month,
|
||
'range' => [$start, $end],
|
||
'summary' => $summary,
|
||
'stats' => [
|
||
'log_days' => count($logs),
|
||
'avg_pain' => $painList ? round(array_sum($painList) / count($painList), 1) : 0,
|
||
'avg_flow' => $flowList ? round(array_sum($flowList) / count($flowList), 1) : 0,
|
||
'avg_sleep' => $sleepCount ? round($sleepTotal / $sleepCount, 1) : 0,
|
||
'top_symptoms' => $topSymptoms,
|
||
'mood_count' => $moodCount,
|
||
'mood_days' => count($moodLogs),
|
||
],
|
||
'access' => [
|
||
'deep' => $deep,
|
||
'need_member' => !$deep,
|
||
'tip' => $deep ? '' : '开通会员可查看深度分析与导出报告',
|
||
],
|
||
'advice' => $this->advice($painList, $sleepCount ? $sleepTotal / $sleepCount : 0, $summary),
|
||
]);
|
||
}
|
||
|
||
protected function advice($painList, $avgSleep, $summary)
|
||
{
|
||
$list = [];
|
||
$avgPain = $painList ? array_sum($painList) / count($painList) : 0;
|
||
if ($avgPain >= 3) {
|
||
$list[] = '本期痛经程度偏高,建议热敷并记录是否影响日常活动;若持续加重请及时就医。';
|
||
}
|
||
if ($avgSleep > 0 && $avgSleep < 6.5) {
|
||
$list[] = '本期平均睡眠 ' . round($avgSleep, 1) . ' 小时,偏少。优先固定起床时间并减少睡前屏幕。';
|
||
}
|
||
if ($summary['regularity'] > 0 && $summary['regularity'] < 70) {
|
||
$list[] = '周期波动略大,建议连续记录 3 个周期后再评估;压力与作息是常见影响因素。';
|
||
}
|
||
if (!$list) {
|
||
$list[] = '本期整体状态平稳,保持规律记录会让预测更准确。';
|
||
}
|
||
$list[] = '以上内容仅供参考,不能替代医疗诊断。';
|
||
return $list;
|
||
}
|
||
|
||
/**
|
||
* 删除某次经期记录(软删除)
|
||
*/
|
||
public function remove()
|
||
{
|
||
$id = (int)$this->request->post('id', 0);
|
||
$row = Db::name('cycle')->where('id', $id)->where('user_id', $this->auth->id)->find();
|
||
if (!$row) {
|
||
$this->error('记录不存在');
|
||
}
|
||
Db::name('cycle')->where('id', $id)->update(['deletetime' => time()]);
|
||
$this->success('已删除');
|
||
}
|
||
}
|