文件
huaqiapp/server/application/api/controller/Cycle.php
linda 5ea1b461f3 feat: 花期 V1.0 纵向切片(暖色调设计系统 + 周期/情绪/会员全链路)
前端(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
2026-11-05 22:54:02 +08:00

343 行
11 KiB
PHP

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<?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('已删除');
}
}