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
这个提交包含在:
@@ -0,0 +1,155 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
<?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('已删除');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\common\controller\Api;
|
||||
use app\common\service\CycleService;
|
||||
use app\common\service\MemberService;
|
||||
use think\Db;
|
||||
|
||||
/**
|
||||
* 首页聚合接口
|
||||
*
|
||||
* 一次请求返回首页全部数据,减少端上串行请求,保证首屏流畅。
|
||||
*/
|
||||
class Home extends Api
|
||||
{
|
||||
protected $noNeedLogin = [];
|
||||
protected $noNeedRight = '*';
|
||||
|
||||
public function index()
|
||||
{
|
||||
$userId = $this->auth->id;
|
||||
$today = date('Y-m-d');
|
||||
$hour = (int)date('H');
|
||||
if ($hour < 6) {
|
||||
$greeting = '夜深了';
|
||||
} elseif ($hour < 11) {
|
||||
$greeting = '早上好';
|
||||
} elseif ($hour < 14) {
|
||||
$greeting = '中午好';
|
||||
} elseif ($hour < 18) {
|
||||
$greeting = '下午好';
|
||||
} else {
|
||||
$greeting = '晚上好';
|
||||
}
|
||||
|
||||
$cycle = CycleService::current($userId);
|
||||
$loggedToday = Db::name('cycle_log')
|
||||
->where('user_id', $userId)
|
||||
->where('log_date', $today)
|
||||
->whereNull('deletetime')
|
||||
->count() > 0;
|
||||
|
||||
$this->success('', [
|
||||
'greeting' => $greeting,
|
||||
'nickname' => $this->auth->nickname ?: '亲爱的',
|
||||
'today' => $today,
|
||||
'date_text' => date('n月j日') . ' ' . $this->weekText($today),
|
||||
'cycle' => $cycle,
|
||||
'logged_today' => $loggedToday,
|
||||
'phase_tip' => $this->phaseTip($cycle),
|
||||
'jijing' => $this->pickJijing($cycle['phase'], $today),
|
||||
'reminders' => $this->reminders($cycle, $loggedToday),
|
||||
'mood' => $this->moodSummary($userId),
|
||||
'member' => MemberService::status($userId),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按阶段/日期稳定挑选一条心语(同一天结果不变)
|
||||
*/
|
||||
protected function pickJijing($phase, $today)
|
||||
{
|
||||
$pool = Db::name('content')
|
||||
->where('type', 'jijing')
|
||||
->where('status', 'normal')
|
||||
->where('verify', 'passed')
|
||||
->order('weigh', 'desc')
|
||||
->select();
|
||||
|
||||
if (!$pool) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$matched = [];
|
||||
foreach ($pool as $item) {
|
||||
$phases = array_filter(explode(',', $item['phase']));
|
||||
if (!$phase || in_array('all', $phases) || in_array($phase, $phases)) {
|
||||
$matched[] = $item;
|
||||
}
|
||||
}
|
||||
if (!$matched) {
|
||||
$matched = $pool;
|
||||
}
|
||||
|
||||
$index = crc32($today . ($phase ?: 'all')) % count($matched);
|
||||
$item = $matched[$index];
|
||||
|
||||
return [
|
||||
'id' => (int)$item['id'],
|
||||
'title' => $item['title'],
|
||||
'summary' => $item['summary'],
|
||||
'body' => $item['body'],
|
||||
'emoji' => $item['cover_emoji'],
|
||||
'access' => $item['access'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 阶段建议文案
|
||||
*/
|
||||
protected function phaseTip($cycle)
|
||||
{
|
||||
if (!$cycle['has_data']) {
|
||||
return [
|
||||
'title' => '先记录一次经期',
|
||||
'desc' => '记录后即可获得阶段预测与个性化建议',
|
||||
'action' => '去记录',
|
||||
];
|
||||
}
|
||||
$map = [
|
||||
'menstrual' => ['keyword' => '这几天适合放慢节奏', 'desc' => '注意保暖、补铁,减少高强度运动'],
|
||||
'follicular' => ['keyword' => '能量回升期', 'desc' => '精力与情绪较好,适合安排计划与运动'],
|
||||
'ovulation' => ['keyword' => '易孕期', 'desc' => '宫颈黏液变清亮,注意私处清洁与补水'],
|
||||
'luteal' => ['keyword' => '黄体期', 'desc' => '情绪易波动,优先睡眠与温热饮食'],
|
||||
];
|
||||
$tip = $map[$cycle['phase']] ?: $map['follicular'];
|
||||
return [
|
||||
'title' => $tip['keyword'],
|
||||
'desc' => $tip['desc'],
|
||||
'action' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 今日提醒(由周期与记录状态推导,非冗余推送)
|
||||
*/
|
||||
protected function reminders($cycle, $loggedToday)
|
||||
{
|
||||
$list = [];
|
||||
if (!$loggedToday) {
|
||||
$list[] = [
|
||||
'type' => 'record',
|
||||
'icon' => '✍️',
|
||||
'title' => '今天还没有记录',
|
||||
'desc' => '花 10 秒记录身体感受,预测会更准',
|
||||
];
|
||||
}
|
||||
if ($cycle['has_data']) {
|
||||
if ($cycle['is_period']) {
|
||||
$list[] = [
|
||||
'type' => 'care',
|
||||
'icon' => '🌡',
|
||||
'title' => '经期第 ' . $cycle['day'] . ' 天',
|
||||
'desc' => '小腹保暖 + 温热饮食,避免冰饮',
|
||||
];
|
||||
} elseif ($cycle['is_fertile']) {
|
||||
$list[] = [
|
||||
'type' => 'fertile',
|
||||
'icon' => '🌱',
|
||||
'title' => '易孕期',
|
||||
'desc' => '排卵期前后,注意休息与营养',
|
||||
];
|
||||
} elseif ($cycle['days_to_next'] !== null && $cycle['days_to_next'] <= 3 && $cycle['days_to_next'] >= 1) {
|
||||
$list[] = [
|
||||
'type' => 'period',
|
||||
'icon' => '📅',
|
||||
'title' => '经期还有 ' . $cycle['days_to_next'] . ' 天',
|
||||
'desc' => '可以提前准备卫生用品与暖贴',
|
||||
];
|
||||
} elseif ($cycle['phase'] === 'luteal') {
|
||||
$list[] = [
|
||||
'type' => 'mood',
|
||||
'icon' => '🍂',
|
||||
'title' => '黄体期留意情绪',
|
||||
'desc' => '敏感易累是生理性的,允许自己慢一点',
|
||||
];
|
||||
}
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* 近 7 天情绪概览
|
||||
*/
|
||||
protected function moodSummary($userId)
|
||||
{
|
||||
$start = date('Y-m-d', strtotime('-6 day'));
|
||||
$logs = Db::name('mood_log')
|
||||
->where('user_id', $userId)
|
||||
->whereNull('deletetime')
|
||||
->where('log_date', '>=', $start)
|
||||
->order('log_date', 'asc')
|
||||
->select();
|
||||
|
||||
$scoreMap = ['happy' => 5, 'calm' => 4, 'low' => 2, 'anxious' => 2, 'angry' => 1];
|
||||
$days = [];
|
||||
for ($i = 6; $i >= 0; $i--) {
|
||||
$d = date('Y-m-d', strtotime('-' . $i . ' day'));
|
||||
$days[$d] = ['date' => $d, 'score' => null, 'mood' => ''];
|
||||
}
|
||||
foreach ($logs as $log) {
|
||||
if (isset($days[$log['log_date']])) {
|
||||
$days[$log['log_date']]['score'] = isset($scoreMap[$log['mood']]) ? $scoreMap[$log['mood']] : 3;
|
||||
$days[$log['log_date']]['mood'] = $log['mood'];
|
||||
}
|
||||
}
|
||||
|
||||
$recorded = array_values(array_filter($days, function ($d) {
|
||||
return $d['score'] !== null;
|
||||
}));
|
||||
$avg = $recorded ? round(array_sum(array_column($recorded, 'score')) / count($recorded), 1) : 0;
|
||||
|
||||
return [
|
||||
'days' => array_values($days),
|
||||
'recorded' => count($recorded),
|
||||
'avg_score' => $avg,
|
||||
'today_mood' => isset($days[date('Y-m-d')]) ? $days[date('Y-m-d')]['mood'] : '',
|
||||
];
|
||||
}
|
||||
|
||||
protected function weekText($date)
|
||||
{
|
||||
$weeks = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
return '星期' . $weeks[(int)date('w', strtotime($date))];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\common\controller\Api;
|
||||
use app\common\service\MemberService;
|
||||
use fast\Random;
|
||||
use think\Db;
|
||||
|
||||
/**
|
||||
* 会员接口(M6 变现)
|
||||
*/
|
||||
class Member extends Api
|
||||
{
|
||||
protected $noNeedLogin = ['plans'];
|
||||
protected $noNeedRight = '*';
|
||||
|
||||
/**
|
||||
* 套餐列表(无需登录)
|
||||
*/
|
||||
public function plans()
|
||||
{
|
||||
$this->success('', ['list' => MemberService::plans()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的会员状态
|
||||
*/
|
||||
public function status()
|
||||
{
|
||||
$status = MemberService::status($this->auth->id);
|
||||
$orders = Db::name('member_order')
|
||||
->where('user_id', $this->auth->id)
|
||||
->order('id', 'desc')
|
||||
->limit(10)
|
||||
->field('order_no,plan_code,amount,pay_status,createtime,start_time,expire_time')
|
||||
->select();
|
||||
foreach ($orders as &$order) {
|
||||
$order['amount'] = (float)$order['amount'];
|
||||
$order['create_date'] = $order['createtime'] ? date('Y-m-d H:i', (int)$order['createtime']) : '';
|
||||
$order['status_text'] = ['unpaid' => '待支付', 'paid' => '已支付', 'refunded' => '已退款'][$order['pay_status']] ?? $order['pay_status'];
|
||||
}
|
||||
$this->success('', ['member' => $status, 'orders' => $orders]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建会员订单
|
||||
* POST plan_id=2 pay_type=wechat
|
||||
*/
|
||||
public function order()
|
||||
{
|
||||
$userId = $this->auth->id;
|
||||
$planId = (int)$this->request->post('plan_id', 0);
|
||||
$payType = $this->request->post('pay_type', 'wechat');
|
||||
|
||||
$plan = Db::name('member_plan')->where('id', $planId)->where('status', 'normal')->find();
|
||||
if (!$plan) {
|
||||
$this->error('套餐不存在或已下架');
|
||||
}
|
||||
if (!in_array($payType, ['wechat', 'alipay'])) {
|
||||
$this->error('不支持的支付方式');
|
||||
}
|
||||
|
||||
$orderNo = 'HQ' . date('YmdHis') . Random::numeric(4);
|
||||
$now = time();
|
||||
$id = Db::name('member_order')->insertGetId([
|
||||
'order_no' => $orderNo,
|
||||
'user_id' => $userId,
|
||||
'plan_id' => (int)$plan['id'],
|
||||
'plan_code' => $plan['code'],
|
||||
'amount' => $plan['price'],
|
||||
'pay_type' => $payType,
|
||||
'pay_status' => 'unpaid',
|
||||
'createtime' => $now,
|
||||
'updatetime' => $now,
|
||||
]);
|
||||
|
||||
$this->success('订单已创建', [
|
||||
'order_id' => (int)$id,
|
||||
'order_no' => $orderNo,
|
||||
'amount' => (float)$plan['price'],
|
||||
'plan' => [
|
||||
'code' => $plan['code'],
|
||||
'name' => $plan['name'],
|
||||
],
|
||||
'pay_type' => $payType,
|
||||
'pay_hint' => '支付通道(微信/易支付)待接入,可由后台确认订单后开通权益',
|
||||
'pay_status' => 'unpaid',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\common\controller\Api;
|
||||
use think\Db;
|
||||
use think\Validate;
|
||||
|
||||
/**
|
||||
* 情绪疗愈接口(M2)
|
||||
*/
|
||||
class Mood extends Api
|
||||
{
|
||||
protected $noNeedLogin = [];
|
||||
protected $noNeedRight = '*';
|
||||
|
||||
/** 情绪枚举 */
|
||||
public static $moods = [
|
||||
'happy' => ['text' => '开心', 'emoji' => '☀️', 'score' => 5, 'color' => '#F5A65B'],
|
||||
'calm' => ['text' => '平静', 'emoji' => '🌤', 'score' => 4, 'color' => '#9BC1A6'],
|
||||
'anxious' => ['text' => '焦虑', 'emoji' => '🌪', 'score' => 2, 'color' => '#E8A87C'],
|
||||
'low' => ['text' => '低落', 'emoji' => '🌧', 'score' => 2, 'color' => '#8FA6C4'],
|
||||
'angry' => ['text' => '烦躁', 'emoji' => '⚡', 'score' => 1, 'color' => '#E8604C'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 记录情绪(一天可多条)
|
||||
* POST log_date, mood, intensity, triggers, content
|
||||
*/
|
||||
public function save()
|
||||
{
|
||||
$userId = $this->auth->id;
|
||||
$mood = (string)$this->request->post('mood', '');
|
||||
if (!isset(self::$moods[$mood])) {
|
||||
$this->error('请选择情绪');
|
||||
}
|
||||
$logDate = $this->request->post('log_date', date('Y-m-d'));
|
||||
if (!strtotime($logDate)) {
|
||||
$this->error('日期格式不正确');
|
||||
}
|
||||
if (strtotime($logDate) > strtotime('+1 day')) {
|
||||
$this->error('不能记录未来日期');
|
||||
}
|
||||
|
||||
$content = mb_substr((string)$this->request->post('content', ''), 0, 1000);
|
||||
$id = Db::name('mood_log')->insertGetId([
|
||||
'user_id' => $userId,
|
||||
'log_date' => $logDate,
|
||||
'mood' => $mood,
|
||||
'intensity' => min(5, max(1, (int)$this->request->post('intensity', 3))),
|
||||
'triggers' => mb_substr((string)$this->request->post('triggers', ''), 0, 255),
|
||||
'content' => $content,
|
||||
'createtime' => time(),
|
||||
'updatetime' => time(),
|
||||
]);
|
||||
|
||||
$this->success('已记录', [
|
||||
'id' => (int)$id,
|
||||
'mood' => self::$moods[$mood],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 情绪列表
|
||||
* GET limit=20 month=2026-11
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$userId = $this->auth->id;
|
||||
$limit = min(100, max(1, (int)$this->request->param('limit', 20)));
|
||||
$month = $this->request->param('month', '');
|
||||
|
||||
$query = Db::name('mood_log')
|
||||
->where('user_id', $userId)
|
||||
->whereNull('deletetime');
|
||||
|
||||
if ($month && Validate::regex($month, '/^\d{4}-\d{2}$/')) {
|
||||
$query->whereBetween('log_date', [$month . '-01', date('Y-m-t', strtotime($month . '-01'))]);
|
||||
}
|
||||
|
||||
$list = $query->order('log_date', 'desc')->order('id', 'desc')->limit($limit)->select();
|
||||
$list = array_map([$this, 'format'], $list);
|
||||
|
||||
$this->success('', ['list' => $list, 'moods' => $this->moodOptions()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 情绪趋势
|
||||
* GET days=7|30
|
||||
*/
|
||||
public function trend()
|
||||
{
|
||||
$userId = $this->auth->id;
|
||||
$days = (int)$this->request->param('days', 7);
|
||||
$days = in_array($days, [7, 14, 30]) ? $days : 7;
|
||||
|
||||
$start = date('Y-m-d', strtotime('-' . ($days - 1) . ' day'));
|
||||
$logs = Db::name('mood_log')
|
||||
->where('user_id', $userId)
|
||||
->whereNull('deletetime')
|
||||
->where('log_date', '>=', $start)
|
||||
->order('log_date', 'asc')
|
||||
->select();
|
||||
|
||||
$bucket = [];
|
||||
for ($i = $days - 1; $i >= 0; $i--) {
|
||||
$d = date('Y-m-d', strtotime('-' . $i . ' day'));
|
||||
$bucket[$d] = ['date' => $d, 'label' => date('n/j', strtotime($d)), 'score' => null, 'count' => 0, 'mood' => ''];
|
||||
}
|
||||
|
||||
foreach ($logs as $log) {
|
||||
if (!isset($bucket[$log['log_date']])) {
|
||||
continue;
|
||||
}
|
||||
$score = self::$moods[$log['mood']]['score'] ?? 3;
|
||||
$item = &$bucket[$log['log_date']];
|
||||
$item['count']++;
|
||||
$item['score'] = $item['score'] === null ? $score : round(($item['score'] * ($item['count'] - 1) + $score) / $item['count'], 1);
|
||||
$item['mood'] = $log['mood'];
|
||||
unset($item);
|
||||
}
|
||||
|
||||
$recorded = array_values(array_filter($bucket, function ($v) {
|
||||
return $v['score'] !== null;
|
||||
}));
|
||||
$avg = $recorded ? round(array_sum(array_column($recorded, 'score')) / count($recorded), 1) : 0;
|
||||
|
||||
// 主情绪统计
|
||||
$count = [];
|
||||
foreach ($logs as $log) {
|
||||
$count[$log['mood']] = isset($count[$log['mood']]) ? $count[$log['mood']] + 1 : 1;
|
||||
}
|
||||
arsort($count);
|
||||
$topMood = key($count) ?: '';
|
||||
|
||||
$this->success('', [
|
||||
'days' => $days,
|
||||
'list' => array_values($bucket),
|
||||
'recorded' => count($recorded),
|
||||
'avg_score' => $avg,
|
||||
'top_mood' => $topMood ? array_merge(['key' => $topMood], self::$moods[$topMood]) : null,
|
||||
'moods' => $this->moodOptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除一条情绪记录
|
||||
*/
|
||||
public function remove()
|
||||
{
|
||||
$id = (int)$this->request->post('id', 0);
|
||||
$row = Db::name('mood_log')->where('id', $id)->where('user_id', $this->auth->id)->find();
|
||||
if (!$row) {
|
||||
$this->error('记录不存在');
|
||||
}
|
||||
Db::name('mood_log')->where('id', $id)->update(['deletetime' => time()]);
|
||||
$this->success('已删除');
|
||||
}
|
||||
|
||||
protected function moodOptions()
|
||||
{
|
||||
$list = [];
|
||||
foreach (self::$moods as $key => $item) {
|
||||
$list[] = array_merge(['key' => $key], $item);
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
protected function format($log)
|
||||
{
|
||||
$mood = self::$moods[$log['mood']] ?? ['text' => '', 'emoji' => '', 'score' => 0, 'color' => ''];
|
||||
return [
|
||||
'id' => (int)$log['id'],
|
||||
'log_date' => $log['log_date'],
|
||||
'date_text' => date('n月j日', strtotime($log['log_date'])),
|
||||
'week_text' => '周' . ['日', '一', '二', '三', '四', '五', '六'][(int)date('w', strtotime($log['log_date']))],
|
||||
'mood' => $log['mood'],
|
||||
'mood_text' => $mood['text'],
|
||||
'emoji' => $mood['emoji'],
|
||||
'color' => $mood['color'],
|
||||
'intensity' => (int)$log['intensity'],
|
||||
'triggers' => $log['triggers'],
|
||||
'content' => $log['content'],
|
||||
'time_text' => date('H:i', (int)$log['createtime']),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\common\controller\Api;
|
||||
use think\Db;
|
||||
|
||||
/**
|
||||
* 隐私与提醒设置(M6)
|
||||
*/
|
||||
class Setting extends Api
|
||||
{
|
||||
protected $noNeedLogin = [];
|
||||
protected $noNeedRight = '*';
|
||||
|
||||
protected $defaultRemind = [
|
||||
'period' => true, // 经期前提醒
|
||||
'ovulation' => true, // 排卵日提醒
|
||||
'record' => true, // 每日记录提醒
|
||||
'care' => true, // 关怀提醒(家人共护)
|
||||
'time' => '21:00',
|
||||
];
|
||||
|
||||
/**
|
||||
* 读取设置
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$setting = Db::name('user_setting')->where('user_id', $this->auth->id)->find();
|
||||
if (!$setting) {
|
||||
$setting = [
|
||||
'app_lock' => 'none',
|
||||
'local_only' => 0,
|
||||
'remind_json' => json_encode($this->defaultRemind),
|
||||
];
|
||||
}
|
||||
$remind = json_decode($setting['remind_json'], true) ?: $this->defaultRemind;
|
||||
$remind = array_merge($this->defaultRemind, $remind);
|
||||
|
||||
$this->success('', [
|
||||
'app_lock' => $setting['app_lock'],
|
||||
'has_lock_code' => !empty($setting['app_lock_value']),
|
||||
'local_only' => (int)$setting['local_only'],
|
||||
'remind' => $remind,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存设置
|
||||
* POST app_lock=none|password|biometric, app_lock_code=1234, local_only=0|1, remind={"period":true,...}
|
||||
*/
|
||||
public function save()
|
||||
{
|
||||
$userId = $this->auth->id;
|
||||
$exist = Db::name('user_setting')->where('user_id', $userId)->find();
|
||||
|
||||
$data = ['updatetime' => time()];
|
||||
|
||||
$appLock = $this->request->post('app_lock', null);
|
||||
if ($appLock !== null) {
|
||||
if (!in_array($appLock, ['none', 'password', 'biometric'])) {
|
||||
$this->error('应用锁类型不正确');
|
||||
}
|
||||
$data['app_lock'] = $appLock;
|
||||
}
|
||||
|
||||
$code = (string)$this->request->post('app_lock_code', '');
|
||||
if ($code !== '') {
|
||||
if (!preg_match('/^\d{4,6}$/', $code)) {
|
||||
$this->error('应用锁密码需为 4-6 位数字');
|
||||
}
|
||||
// 仅存哈希,不落明文
|
||||
$data['app_lock_value'] = md5($code . 'huāqī');
|
||||
}
|
||||
|
||||
$localOnly = $this->request->post('local_only', null);
|
||||
if ($localOnly !== null) {
|
||||
$data['local_only'] = (int)!!$localOnly;
|
||||
}
|
||||
|
||||
$remind = $this->readRemind();
|
||||
if ($remind !== null) {
|
||||
$merged = array_merge($this->defaultRemind, array_intersect_key($remind, $this->defaultRemind));
|
||||
if (isset($merged['time']) && !preg_match('/^\d{2}:\d{2}$/', $merged['time'])) {
|
||||
$merged['time'] = '21:00';
|
||||
}
|
||||
$data['remind_json'] = json_encode($merged, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
if ($exist) {
|
||||
Db::name('user_setting')->where('id', $exist['id'])->update($data);
|
||||
} else {
|
||||
$data['user_id'] = $userId;
|
||||
$data['createtime'] = time();
|
||||
Db::name('user_setting')->insert($data);
|
||||
}
|
||||
|
||||
$this->success('已保存');
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取提醒配置:支持 remind_xxx 独立字段(推荐,兼容 request filter)与 remind JSON
|
||||
* @return array|null
|
||||
*/
|
||||
protected function readRemind()
|
||||
{
|
||||
$keys = ['period', 'ovulation', 'record', 'care', 'time'];
|
||||
$result = [];
|
||||
$hasField = false;
|
||||
foreach ($keys as $key) {
|
||||
$value = $this->request->post('remind_' . $key, null);
|
||||
if ($value !== null) {
|
||||
$hasField = true;
|
||||
$result[$key] = $key === 'time' ? (string)$value : (bool)(int)$value;
|
||||
}
|
||||
}
|
||||
if ($hasField) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// 兼容 JSON 传参(注意:全局 filter 会把引号转义,需还原)
|
||||
$json = $this->request->post('remind', null);
|
||||
if ($json === null || $json === '') {
|
||||
return null;
|
||||
}
|
||||
$arr = is_array($json) ? $json : json_decode(htmlspecialchars_decode((string)$json, ENT_QUOTES), true);
|
||||
return is_array($arr) ? $arr : null;
|
||||
}
|
||||
}
|
||||
@@ -353,4 +353,71 @@ class User extends Api
|
||||
$this->error($this->auth->getError());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的资料(花期 APP 个人中心)
|
||||
* GET
|
||||
*/
|
||||
public function detail()
|
||||
{
|
||||
$user = \think\Db::name('user')
|
||||
->where('id', $this->auth->id)
|
||||
->field('id,username,nickname,avatar,mobile,gender,birthday,bio')
|
||||
->find();
|
||||
$setting = \think\Db::name('user_setting')->where('user_id', $this->auth->id)->find();
|
||||
$summary = \app\common\service\CycleService::summary($this->auth->id);
|
||||
|
||||
$this->success('', [
|
||||
'user' => [
|
||||
'id' => (int)$user['id'],
|
||||
'username' => $user['username'],
|
||||
'nickname' => $user['nickname'],
|
||||
'avatar' => $user['avatar'],
|
||||
'mobile' => $user['mobile'],
|
||||
'gender' => (int)$user['gender'],
|
||||
'birthday' => $user['birthday'],
|
||||
'bio' => $user['bio'],
|
||||
],
|
||||
'summary' => $summary,
|
||||
'member' => \app\common\service\MemberService::status($this->auth->id),
|
||||
'privacy' => [
|
||||
'app_lock' => $setting ? $setting['app_lock'] : 'none',
|
||||
'local_only' => $setting ? (int)$setting['local_only'] : 0,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新资料
|
||||
* POST nickname, avatar, gender, birthday, bio
|
||||
*/
|
||||
public function updateprofile()
|
||||
{
|
||||
$data = [];
|
||||
foreach (['nickname', 'avatar', 'bio'] as $field) {
|
||||
$value = $this->request->post($field, null);
|
||||
if ($value !== null) {
|
||||
$data[$field] = mb_substr((string)$value, 0, $field === 'bio' ? 100 : 50);
|
||||
}
|
||||
}
|
||||
$gender = $this->request->post('gender', null);
|
||||
if ($gender !== null) {
|
||||
$data['gender'] = (int)$gender === 2 ? 2 : ((int)$gender === 1 ? 1 : 0);
|
||||
}
|
||||
$birthday = $this->request->post('birthday', '');
|
||||
if ($birthday) {
|
||||
if (!strtotime($birthday)) {
|
||||
$this->error('生日格式不正确');
|
||||
}
|
||||
$data['birthday'] = date('Y-m-d', strtotime($birthday));
|
||||
}
|
||||
if (!$data) {
|
||||
$this->error('没有需要更新的内容');
|
||||
}
|
||||
|
||||
$data['updatetime'] = time();
|
||||
\think\Db::name('user')->where('id', $this->auth->id)->update($data);
|
||||
|
||||
$this->success('已更新');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use think\Db;
|
||||
|
||||
/**
|
||||
* 周期计算服务
|
||||
*
|
||||
* 统一为「周期管理 / 情绪疗愈 / 健康建议 / 首页聚合」提供四阶段计算与预测能力,
|
||||
* 避免各控制器重复实现。所有日期均为 Y-m-d 字符串。
|
||||
*/
|
||||
class CycleService
|
||||
{
|
||||
const PHASE_MENSTRUAL = 'menstrual';
|
||||
const PHASE_FOLLICULAR = 'follicular';
|
||||
const PHASE_OVULATION = 'ovulation';
|
||||
const PHASE_LUTEAL = 'luteal';
|
||||
|
||||
public static $phaseText = [
|
||||
'menstrual' => '经期',
|
||||
'follicular' => '卵泡期',
|
||||
'ovulation' => '排卵期',
|
||||
'luteal' => '黄体期',
|
||||
];
|
||||
|
||||
/** 各阶段的品牌色(与前端设计令牌保持一致) */
|
||||
public static $phaseColor = [
|
||||
'menstrual' => '#E8604C',
|
||||
'follicular' => '#F5A65B',
|
||||
'ovulation' => '#F0796A',
|
||||
'luteal' => '#C79A7B',
|
||||
];
|
||||
|
||||
/**
|
||||
* 取用户周期列表(按开始日倒序)
|
||||
*/
|
||||
public static function cycles($userId, $limit = 12)
|
||||
{
|
||||
return Db::name('cycle')
|
||||
->where('user_id', $userId)
|
||||
->whereNull('deletetime')
|
||||
->order('start_date', 'desc')
|
||||
->limit($limit)
|
||||
->select();
|
||||
}
|
||||
|
||||
/**
|
||||
* 周期参数(周期长度 / 经期长度),支持用户自定义后取历史均值
|
||||
*/
|
||||
public static function params($userId)
|
||||
{
|
||||
$cycles = self::cycles($userId, 6);
|
||||
$cycleLength = 28;
|
||||
$periodLength = 5;
|
||||
|
||||
if ($cycles) {
|
||||
// 用最近 6 个周期的平均长度
|
||||
$lengths = [];
|
||||
foreach ($cycles as $c) {
|
||||
if ($c['cycle_length'] > 0) {
|
||||
$lengths[] = (int)$c['cycle_length'];
|
||||
}
|
||||
}
|
||||
$diff = [];
|
||||
for ($i = 0; $i < count($cycles) - 1; $i++) {
|
||||
$days = (strtotime($cycles[$i]['start_date']) - strtotime($cycles[$i + 1]['start_date'])) / 86400;
|
||||
if ($days >= 18 && $days <= 60) {
|
||||
$diff[] = (int)$days;
|
||||
}
|
||||
}
|
||||
if ($diff) {
|
||||
$cycleLength = (int)round(array_sum($diff) / count($diff));
|
||||
} elseif ($lengths) {
|
||||
$cycleLength = (int)round(array_sum($lengths) / count($lengths));
|
||||
}
|
||||
$periodLength = (int)$cycles[0]['period_length'] ?: 5;
|
||||
}
|
||||
|
||||
return ['cycle_length' => $cycleLength, 'period_length' => $periodLength];
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前周期状态(首页 / 记录页核心数据)
|
||||
*/
|
||||
public static function current($userId, $today = null)
|
||||
{
|
||||
$today = $today ?: date('Y-m-d');
|
||||
$now = strtotime($today);
|
||||
$cycles = self::cycles($userId);
|
||||
$params = self::params($userId);
|
||||
|
||||
$base = [
|
||||
'has_data' => false,
|
||||
'today' => $today,
|
||||
'phase' => null,
|
||||
'phase_text' => '',
|
||||
'phase_color' => '',
|
||||
'day' => 0,
|
||||
'cycle_length' => $params['cycle_length'],
|
||||
'period_length' => $params['period_length'],
|
||||
'start_date' => null,
|
||||
'next_start' => null,
|
||||
'days_to_next' => null,
|
||||
'ovulation' => null,
|
||||
'fertile_start' => null,
|
||||
'fertile_end' => null,
|
||||
'is_fertile' => false,
|
||||
'is_period' => false,
|
||||
'progress' => 0,
|
||||
];
|
||||
|
||||
if (!$cycles) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
// 找到覆盖今天的周期(start <= today 且 today < start + cycleLength)
|
||||
$active = null;
|
||||
foreach ($cycles as $c) {
|
||||
$start = strtotime($c['start_date']);
|
||||
if ($start <= $now && $now < $start + $params['cycle_length'] * 86400) {
|
||||
$active = $c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 今天早于最新周期开始日(补录历史)时,退化使用最新周期推算
|
||||
if (!$active) {
|
||||
$active = $cycles[0];
|
||||
}
|
||||
|
||||
$start = strtotime($active['start_date']);
|
||||
$day = (int)floor(($now - $start) / 86400) + 1;
|
||||
$phase = self::phaseByDay($day, $params['cycle_length'], $params['period_length']);
|
||||
$ovulationTs = $start + ($params['cycle_length'] - 14) * 86400;
|
||||
$nextStartTs = $start + $params['cycle_length'] * 86400;
|
||||
$daysToNext = (int)ceil(($nextStartTs - $now) / 86400);
|
||||
|
||||
$result = array_merge($base, [
|
||||
'has_data' => true,
|
||||
'phase' => $phase,
|
||||
'phase_text' => self::$phaseText[$phase],
|
||||
'phase_color' => self::$phaseColor[$phase],
|
||||
'day' => max($day, 1),
|
||||
'start_date' => $active['start_date'],
|
||||
'next_start' => date('Y-m-d', $nextStartTs),
|
||||
'days_to_next' => $daysToNext,
|
||||
'ovulation' => date('Y-m-d', $ovulationTs),
|
||||
'fertile_start' => date('Y-m-d', $ovulationTs - 5 * 86400),
|
||||
'fertile_end' => date('Y-m-d', $ovulationTs + 1 * 86400),
|
||||
'is_period' => $phase === self::PHASE_MENSTRUAL,
|
||||
'is_fertile' => $now >= $ovulationTs - 5 * 86400 && $now <= $ovulationTs + 1 * 86400,
|
||||
'progress' => min(100, (int)round(max($day, 1) / $params['cycle_length'] * 100)),
|
||||
]);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据周期内第几天计算阶段
|
||||
*/
|
||||
public static function phaseByDay($day, $cycleLength = 28, $periodLength = 5)
|
||||
{
|
||||
$ovulationDay = max(1, $cycleLength - 14);
|
||||
if ($day <= $periodLength) {
|
||||
return self::PHASE_MENSTRUAL;
|
||||
}
|
||||
if ($day < $ovulationDay - 1) {
|
||||
return self::PHASE_FOLLICULAR;
|
||||
}
|
||||
if ($day <= $ovulationDay + 1) {
|
||||
return self::PHASE_OVULATION;
|
||||
}
|
||||
return self::PHASE_LUTEAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 未来 N 个月的经期/排卵预测
|
||||
*/
|
||||
public static function predict($userId, $months = 3)
|
||||
{
|
||||
$cycles = self::cycles($userId, 1);
|
||||
$params = self::params($userId);
|
||||
$list = [];
|
||||
if (!$cycles) {
|
||||
return ['list' => $list, 'cycle_length' => $params['cycle_length'], 'need_record' => true];
|
||||
}
|
||||
|
||||
$start = strtotime($cycles[0]['start_date']);
|
||||
$today = strtotime(date('Y-m-d'));
|
||||
$limit = strtotime('+' . $months . ' month');
|
||||
$i = 1;
|
||||
while (true) {
|
||||
$nextStart = $start + $i * $params['cycle_length'] * 86400;
|
||||
if ($nextStart > $limit) {
|
||||
break;
|
||||
}
|
||||
$ovulation = $nextStart + ($params['cycle_length'] - 14) * 86400;
|
||||
$list[] = [
|
||||
'start' => date('Y-m-d', $nextStart),
|
||||
'end' => date('Y-m-d', $nextStart + ($params['period_length'] - 1) * 86400),
|
||||
'ovulation' => date('Y-m-d', $ovulation),
|
||||
'fertile_start' => date('Y-m-d', $ovulation - 5 * 86400),
|
||||
'fertile_end' => date('Y-m-d', $ovulation + 86400),
|
||||
'days_from_now' => (int)ceil(($nextStart - $today) / 86400),
|
||||
];
|
||||
$i++;
|
||||
}
|
||||
|
||||
return ['list' => $list, 'cycle_length' => $params['cycle_length'], 'need_record' => false];
|
||||
}
|
||||
|
||||
/**
|
||||
* 月历:某月每一天的阶段信息
|
||||
*/
|
||||
public static function calendar($userId, $month)
|
||||
{
|
||||
$first = strtotime($month . '-01');
|
||||
$daysInMonth = (int)date('t', $first);
|
||||
$today = date('Y-m-d');
|
||||
|
||||
$cycles = self::cycles($userId, 24);
|
||||
$params = self::params($userId);
|
||||
$predict = self::predict($userId, 4);
|
||||
$logs = Db::name('cycle_log')
|
||||
->where('user_id', $userId)
|
||||
->whereNull('deletetime')
|
||||
->whereBetween('log_date', [$month . '-01', $month . '-' . $daysInMonth])
|
||||
->column('log_date');
|
||||
|
||||
$list = [];
|
||||
for ($d = 1; $d <= $daysInMonth; $d++) {
|
||||
$date = date('Y-m-d', $first + ($d - 1) * 86400);
|
||||
$ts = strtotime($date);
|
||||
|
||||
$phase = null;
|
||||
$inPeriod = false;
|
||||
$isPredicted = false;
|
||||
|
||||
// 1) 真实周期覆盖
|
||||
foreach ($cycles as $c) {
|
||||
$cStart = strtotime($c['start_date']);
|
||||
if ($ts >= $cStart && $ts < $cStart + $params['cycle_length'] * 86400) {
|
||||
$day = (int)floor(($ts - $cStart) / 86400) + 1;
|
||||
$phase = self::phaseByDay($day, $params['cycle_length'], $params['period_length']);
|
||||
$inPeriod = $day <= $params['period_length'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 预测经期
|
||||
if (!$phase) {
|
||||
foreach ($predict['list'] as $p) {
|
||||
if ($date >= $p['start'] && $date <= $p['end']) {
|
||||
$phase = self::PHASE_MENSTRUAL;
|
||||
$inPeriod = true;
|
||||
$isPredicted = true;
|
||||
break;
|
||||
}
|
||||
if ($date >= $p['fertile_start'] && $date <= $p['fertile_end']) {
|
||||
$phase = self::PHASE_OVULATION;
|
||||
$isPredicted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$list[] = [
|
||||
'date' => $date,
|
||||
'day' => $d,
|
||||
'week' => (int)date('w', $ts),
|
||||
'phase' => $phase ?: 'unknown',
|
||||
'phase_text' => $phase ? self::$phaseText[$phase] : '',
|
||||
'color' => $phase ? self::$phaseColor[$phase] : '',
|
||||
'in_period' => $inPeriod,
|
||||
'is_predicted' => $isPredicted,
|
||||
'has_log' => in_array($date, $logs),
|
||||
'is_today' => $date === $today,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'month' => $month,
|
||||
'list' => $list,
|
||||
'today' => $today,
|
||||
'cycle' => self::current($userId),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 周期汇总(平均长度、规律性评分、记录量)
|
||||
*/
|
||||
public static function summary($userId)
|
||||
{
|
||||
$cycles = self::cycles($userId, 12);
|
||||
$params = self::params($userId);
|
||||
$diffs = [];
|
||||
for ($i = 0; $i < count($cycles) - 1; $i++) {
|
||||
$days = (int)((strtotime($cycles[$i]['start_date']) - strtotime($cycles[$i + 1]['start_date'])) / 86400);
|
||||
if ($days >= 18 && $days <= 60) {
|
||||
$diffs[] = $days;
|
||||
}
|
||||
}
|
||||
|
||||
$variance = 0;
|
||||
if ($diffs) {
|
||||
$avg = array_sum($diffs) / count($diffs);
|
||||
foreach ($diffs as $d) {
|
||||
$variance += pow($d - $avg, 2);
|
||||
}
|
||||
$variance = sqrt($variance / count($diffs));
|
||||
}
|
||||
|
||||
// 规律性:标准差越小分越高(0 天 → 100 分,≥5 天 → 60 分以下)
|
||||
$regularity = $diffs ? max(50, (int)round(100 - $variance * 8)) : 0;
|
||||
$regularityText = $regularity >= 85 ? '非常规律' : ($regularity >= 70 ? '较为规律' : ($regularity > 0 ? '略有波动' : '数据不足'));
|
||||
|
||||
return [
|
||||
'cycle_length' => $params['cycle_length'],
|
||||
'period_length' => $params['period_length'],
|
||||
'regularity' => $regularity,
|
||||
'regularity_text' => $regularityText,
|
||||
'cycle_count' => count($cycles),
|
||||
'log_count' => Db::name('cycle_log')->where('user_id', $userId)->whereNull('deletetime')->count(),
|
||||
'mood_count' => Db::name('mood_log')->where('user_id', $userId)->whereNull('deletetime')->count(),
|
||||
'last_start' => $cycles ? $cycles[0]['start_date'] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use think\Db;
|
||||
|
||||
/**
|
||||
* 会员权益服务
|
||||
*
|
||||
* 会员判定统一出口:订单表 fa_member_order 中「已支付 + 未过期」的最近一条即当前会员。
|
||||
* 权益标识与 docs/01-产品功能模块清单.md 第 12 节权益矩阵保持一致。
|
||||
*/
|
||||
class MemberService
|
||||
{
|
||||
/** 权益标识 → 中文名(用于前端展示) */
|
||||
public static $benefitText = [
|
||||
'report.deep' => '深度健康报告',
|
||||
'report.annual' => '年度报告精装版',
|
||||
'prepare.plan' => '精准备孕计划',
|
||||
'prepare.intercourse' => '同房指导',
|
||||
'lhtest.quota' => '试纸识别额度',
|
||||
'content.theme' => '专属心语主题包',
|
||||
'meditation.deep' => '深度冥想课程',
|
||||
'sync.multi' => '多设备同步',
|
||||
'course.discount' => '课程折扣',
|
||||
'consult.discount' => '咨询折扣',
|
||||
'identity.lifetime' => '终身会员标识',
|
||||
];
|
||||
|
||||
/**
|
||||
* 当前有效会员订单
|
||||
*/
|
||||
public static function activeOrder($userId)
|
||||
{
|
||||
$now = time();
|
||||
return Db::name('member_order')
|
||||
->where('user_id', $userId)
|
||||
->where('pay_status', 'paid')
|
||||
->where(function ($query) use ($now) {
|
||||
$query->where('expire_time', 0)->whereOr('expire_time', '>', $now);
|
||||
})
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
}
|
||||
|
||||
/**
|
||||
* 会员状态
|
||||
*/
|
||||
public static function status($userId)
|
||||
{
|
||||
$order = self::activeOrder($userId);
|
||||
if (!$order) {
|
||||
return [
|
||||
'is_member' => false,
|
||||
'plan_code' => '',
|
||||
'plan_name' => '',
|
||||
'expire_time' => 0,
|
||||
'days_left' => 0,
|
||||
'forever' => false,
|
||||
'benefits' => [],
|
||||
'benefit_texts' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$plan = Db::name('member_plan')->where('code', $order['plan_code'])->find();
|
||||
$benefits = $plan && $plan['benefits'] ? json_decode($plan['benefits'], true) : [];
|
||||
$forever = (int)$order['expire_time'] === 0;
|
||||
$daysLeft = $forever ? 0 : max(0, (int)ceil(($order['expire_time'] - time()) / 86400));
|
||||
|
||||
return [
|
||||
'is_member' => true,
|
||||
'plan_code' => $order['plan_code'],
|
||||
'plan_name' => $plan ? $plan['name'] : '',
|
||||
'expire_time' => (int)$order['expire_time'],
|
||||
'expire_date' => $forever ? '永久' : date('Y-m-d', (int)$order['expire_time']),
|
||||
'days_left' => $daysLeft,
|
||||
'forever' => $forever,
|
||||
'benefits' => $benefits ?: [],
|
||||
'benefit_texts' => self::texts($benefits ?: []),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否拥有某个权益
|
||||
*/
|
||||
public static function can($userId, $benefit)
|
||||
{
|
||||
$status = self::status($userId);
|
||||
return $status['is_member'] && in_array($benefit, $status['benefits']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 权益标识转中文
|
||||
*/
|
||||
public static function texts($benefits)
|
||||
{
|
||||
$list = [];
|
||||
foreach ((array)$benefits as $b) {
|
||||
$list[] = isset(self::$benefitText[$b]) ? self::$benefitText[$b] : $b;
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* 套餐列表
|
||||
*/
|
||||
public static function plans()
|
||||
{
|
||||
$list = Db::name('member_plan')
|
||||
->where('status', 'normal')
|
||||
->order('weigh', 'desc')
|
||||
->select();
|
||||
foreach ($list as &$item) {
|
||||
$benefits = $item['benefits'] ? json_decode($item['benefits'], true) : [];
|
||||
$item['benefit_texts'] = self::texts($benefits);
|
||||
$item['benefits'] = $benefits ?: [];
|
||||
$item['price'] = (float)$item['price'];
|
||||
$item['origin_price'] = (float)$item['origin_price'];
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户