feat: 花信 v1.0.0 — 月经管理应用
功能: - 首页:周期状态/预测/快捷记录/迷你趋势图 - 日历:月视图 + 经期标记 - 记录:流量/症状/情绪/备注 - 分析:6种可视化模型(折线图/柱状图/雷达图/热力图/气泡图/箱线图)
This commit is contained in:
+108
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<div class="page-content">
|
||||
<router-view />
|
||||
</div>
|
||||
<div class="tab-bar">
|
||||
<div v-for="tab in tabs" :key="tab.path"
|
||||
class="tab-item"
|
||||
:class="{ active: currentTab === tab.path }"
|
||||
@click="goTab(tab.path)">
|
||||
<span class="tab-icon">{{ tab.icon }}</span>
|
||||
<span class="tab-label">{{ tab.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const tabs = [
|
||||
{ path: '/home', title: '首页', icon: '🏠' },
|
||||
{ path: '/calendar', title: '日历', icon: '📅' },
|
||||
{ path: '/record', title: '记录', icon: '✏️' },
|
||||
{ path: '/analytics', title: '分析', icon: '📊' }
|
||||
]
|
||||
|
||||
const currentTab = computed(() => route.path)
|
||||
|
||||
function goTab(path) {
|
||||
if (route.path !== path) router.push(path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 全局样式 */
|
||||
.card {
|
||||
background: #FFFFFF;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin: 0 16px 12px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
|
||||
}
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1E1E1E;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #E893A7;
|
||||
color: #FFFFFF;
|
||||
border-radius: 22px;
|
||||
padding: 12px 0;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary:active { opacity: 0.85; }
|
||||
.stat-number { font-size: 22px; font-weight: 700; color: #1E1E1E; }
|
||||
.stat-label { font-size: 12px; color: #999; }
|
||||
.tag {
|
||||
display: inline-block; padding: 4px 14px; border-radius: 14px; font-size: 12px;
|
||||
margin: 3px; background: #F5F5F5; color: #666; border: 1px solid transparent; cursor: pointer;
|
||||
}
|
||||
.tag-active { background: #F0E6F6; color: #C4A0D9; border-color: #C4A0D9; }
|
||||
.page-scroll { height: calc(100vh - 56px); overflow-y: auto; padding-bottom: 8px; }
|
||||
|
||||
/* Tab Bar */
|
||||
.app-container {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #F8F8F8;
|
||||
}
|
||||
.page-content { flex: 1; overflow: hidden; position: relative; }
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
background: #FFFFFF;
|
||||
border-top: 1px solid #F0F0F0;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.tab-item.active .tab-icon,
|
||||
.tab-item.active .tab-label { color: #E893A7; }
|
||||
.tab-icon { font-size: 20px; color: #999; }
|
||||
.tab-label { font-size: 10px; color: #999; }
|
||||
.tab-item.active .tab-label { font-weight: 500; }
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="cycle-card">
|
||||
<div class="section-title">周期进度</div>
|
||||
<div class="bar-container">
|
||||
<div class="bar-seg" style="width:18%;background:#E893A7">经期</div>
|
||||
<div class="bar-seg" style="width:29%;background:#F0D9E0">卵泡期</div>
|
||||
<div class="bar-seg" style="width:7%;background:#C4A0D9">排卵</div>
|
||||
<div class="bar-seg" style="width:46%;background:#C3FAE8">黄体期</div>
|
||||
<div class="marker" :style="{left: markerPos + '%'}">▼</div>
|
||||
</div>
|
||||
<div class="bar-footer">
|
||||
<span>第{{currentDay}}/{{cycleLength}}天</span>
|
||||
<span :style="{color:phaseColor,fontWeight:500}">{{currentPhase}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
const props = defineProps({ currentDay: {type:Number,default:1}, cycleLength: {type:Number,default:28}, currentPhase: {type:String,default:'经期'}, phaseColor: {type:String,default:'#E893A7'} })
|
||||
const markerPos = computed(() => Math.min((props.currentDay/props.cycleLength)*100, 100))
|
||||
</script>
|
||||
<style scoped>
|
||||
.cycle-card { background:#fff; border-radius:12px; padding:16px; margin:0 16px 12px; box-shadow:0 1px 4px rgba(0,0,0,.04); }
|
||||
.bar-container { position:relative; display:flex; height:28px; border-radius:14px; overflow:hidden; margin:8px 0; }
|
||||
.bar-seg { display:flex; align-items:center; justify-content:center; font-size:10px; color:#fff; font-weight:500; }
|
||||
.marker { position:absolute; top:-8px; transform:translateX(-50%); font-size:10px; color:#E893A7; transition:left .3s; }
|
||||
.bar-footer { display:flex; justify-content:space-between; font-size:12px; color:#999; }
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div class="qa-section">
|
||||
<div class="section-title">快捷记录</div>
|
||||
<div class="qa-row">
|
||||
<div class="qa-btn period-btn" @click="$emit('record')">
|
||||
<span class="qa-icon">🩸</span>
|
||||
<span>记录经期</span>
|
||||
</div>
|
||||
<div class="qa-btn sym-btn" @click="$emit('log')">
|
||||
<span class="qa-icon">💊</span>
|
||||
<span>记录症状</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup> defineEmits(['record','log']) </script>
|
||||
<style scoped>
|
||||
.qa-section { padding:0 16px; margin-bottom:12px; }
|
||||
.qa-row { display:flex; gap:12px; }
|
||||
.qa-btn { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; padding:16px; border-radius:14px; box-shadow:0 1px 4px rgba(0,0,0,.04); cursor:pointer; }
|
||||
.qa-btn:active { opacity:.85; }
|
||||
.period-btn { background:#FCE4EC; }
|
||||
.sym-btn { background:#F0E6F6; }
|
||||
.qa-icon { font-size:28px; margin-bottom:4px; }
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="status-card">
|
||||
<div class="status-label">当前状态</div>
|
||||
<div class="status-main">
|
||||
<span class="status-dot" :style="{color: phaseColor}">●</span>
|
||||
<span class="status-text">周期第{{cycleDay}}天 · {{phase}}</span>
|
||||
</div>
|
||||
<div class="status-sub" v-if="startDate">开始于{{startDate}},预计持续{{periodLength}}天</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
defineProps({ cycleDay: Number, phase: String, phaseColor: String, startDate: String, periodLength: Number })
|
||||
</script>
|
||||
<style scoped>
|
||||
.status-card { background: linear-gradient(135deg,#FCE4EC,#FFF0F3); border-radius: 16px; padding: 20px; margin: 0 16px 12px; border: 1px solid #FCE4EC; }
|
||||
.status-label { font-size: 12px; color: #E893A7; margin-bottom: 4px; }
|
||||
.status-main { display: flex; align-items: center; gap: 8px; }
|
||||
.status-text { font-size: 20px; font-weight: 700; }
|
||||
.status-sub { font-size: 12px; color: #999; margin-top: 4px; }
|
||||
</style>
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import App from './App.vue'
|
||||
import Home from './pages/Home.vue'
|
||||
import Calendar from './pages/Calendar.vue'
|
||||
import Record from './pages/Record.vue'
|
||||
import Analytics from './pages/Analytics.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', redirect: '/home' },
|
||||
{ path: '/home', component: Home, meta: { title: '首页', icon: '🏠', index: 0 } },
|
||||
{ path: '/calendar', component: Calendar, meta: { title: '日历', icon: '📅', index: 1 } },
|
||||
{ path: '/record', component: Record, meta: { title: '记录', icon: '✏️', index: 2 } },
|
||||
{ path: '/analytics', component: Analytics, meta: { title: '分析', icon: '📊', index: 3 } },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div class="page-scroll">
|
||||
<div class="card" style="text-align:center">
|
||||
<div class="stats-row">
|
||||
<div class="stats-item"><div class="stat-number">28</div><div class="stat-label">平均周期</div></div>
|
||||
<div class="stats-divider"></div>
|
||||
<div class="stats-item"><div class="stat-number">5</div><div class="stat-label">平均经期</div></div>
|
||||
<div class="stats-divider"></div>
|
||||
<div class="stats-item"><div class="stat-number">12</div><div class="stat-label">记录周期</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs" style="margin:0 16px 12px;background:#fff;border-radius:12px;padding:8px 16px;white-space:nowrap;overflow-x:auto">
|
||||
<span v-for="(m,i) in models" :key="i" class="tab" :class="{active: active===i}" @click="switchModel(i)">{{m}}</span>
|
||||
</div>
|
||||
|
||||
<div v-show="active===0" class="card">
|
||||
<div class="section-title">📈 周期趋势</div>
|
||||
<div style="display:flex;gap:16px;font-size:11px;color:#999;margin-bottom:4px"><span><span style="display:inline-block;width:20px;height:3px;background:#E893A7;border-radius:2px;vertical-align:middle;margin-right:4px"></span>周期长度</span><span><span style="display:inline-block;width:20px;height:3px;background:repeating-linear-gradient(90deg,#FFD8A8 0,#FFD8A8 6px,transparent 6px,transparent 10px);border-radius:2px;vertical-align:middle;margin-right:4px"></span>28天基准</span></div>
|
||||
<canvas ref="lineRef" class="chart-canvas"></canvas>
|
||||
<div class="chart-summary">最短24天 · 最长32天</div>
|
||||
</div>
|
||||
|
||||
<div v-show="active===1" class="card">
|
||||
<div class="section-title">📊 经期时长</div>
|
||||
<canvas ref="barRef" class="chart-canvas"></canvas>
|
||||
<div class="chart-summary">最长7天 · 最短4天</div>
|
||||
</div>
|
||||
|
||||
<div v-show="active===2" class="card">
|
||||
<div class="section-title">🎯 症状分析</div>
|
||||
<canvas ref="radarRef" class="chart-canvas"></canvas>
|
||||
</div>
|
||||
|
||||
<div v-show="active===3" class="card">
|
||||
<div class="section-title">🌊 年度经期热力图</div>
|
||||
<div class="heatmap">
|
||||
<div v-for="(row,ri) in hRows" :key="ri" class="hrow">
|
||||
<div v-for="(c,ci) in row" :key="ci" class="hcell" :style="{background:c}" @click="alert(['无','少','中','多'][Math.floor(Math.random()*4)])"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="heat-legend"><span>少</span><span class="hl" style="background:#F5F5F5"></span><span class="hl" style="background:#FCE4EC"></span><span class="hl" style="background:#F8BCD4"></span><span class="hl" style="background:#E893A7"></span><span>多</span></div>
|
||||
</div>
|
||||
|
||||
<div v-show="active===4" class="card">
|
||||
<div class="section-title">🔵 情绪流动</div>
|
||||
<canvas ref="bubbleRef" class="chart-canvas"></canvas>
|
||||
<div class="chart-summary" style="color:#C4A0D9">情绪总体偏平稳</div>
|
||||
</div>
|
||||
|
||||
<div v-show="active===5" class="card">
|
||||
<div class="section-title">📋 流量分布</div>
|
||||
<canvas ref="boxRef" class="chart-canvas"></canvas>
|
||||
<div class="flow-dist">
|
||||
<div class="fi" v-for="f in fStats" :key="f.l"><div class="fi-l">{{f.l}}</div><div class="fi-c">{{f.c}}天</div><div class="fi-p">{{f.p}}%</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import * as Mock from '@/utils/mock.js'
|
||||
|
||||
const models = ['📈周期趋势','📊经期时长','🎯症状分析','🌊年度热力','🔵情绪流动','📋流量分布']
|
||||
const active = ref(0)
|
||||
const lineRef = ref(null), barRef = ref(null), radarRef = ref(null), bubbleRef = ref(null), boxRef = ref(null)
|
||||
const hRows = ref([]), fStats = ref([])
|
||||
|
||||
onMounted(() => {
|
||||
// heatmap
|
||||
const daily = Mock.generateDaily()
|
||||
const rows = []
|
||||
for (let w=0; w<12; w++) { const row=[]; for(let d=0;d<31;d++){ const date=`2025-${String(w+1).padStart(2,'0')}-${String(d+1).padStart(2,'0')}`; const rec=daily[date]; const lvl=rec?({'少量':1,'中等':2,'大量':3}[rec.flowLevel]||0):0; row.push(['#F5F5F5','#FCE4EC','#F8BCD4','#E893A7'][lvl]) }; rows.push(row) }
|
||||
hRows.value = rows
|
||||
|
||||
// flow stats
|
||||
const fc = {'无':0,'少量':0,'中等':0,'大量':0}
|
||||
Object.values(daily).forEach(r=>{if(fc[r.flowLevel]!==undefined)fc[r.flowLevel]++})
|
||||
const total = Object.values(fc).reduce((a,b)=>a+b,0)||1
|
||||
fStats.value = Object.entries(fc).map(([l,c])=>({l,c,p:Math.round(c/total*100)}))
|
||||
|
||||
nextTick(() => drawLine())
|
||||
})
|
||||
|
||||
function switchModel(i) { active.value=i; nextTick(()=>{if(i===0)drawLine();else if(i===1)drawBar();else if(i===2)drawRadar();else if(i===4)drawBubble();else if(i===5)drawBox()}) }
|
||||
|
||||
function setupCanvas(ref) {
|
||||
const canvas = ref.value; if(!canvas)return null
|
||||
const rect = canvas.parentElement.getBoundingClientRect()
|
||||
canvas.width = rect.width*2; canvas.height = 220*2; const ctx = canvas.getContext('2d')
|
||||
ctx.scale(2,2); return {ctx, w:rect.width, h:220, canvas}
|
||||
}
|
||||
|
||||
function drawLine() {
|
||||
const r = setupCanvas(lineRef); if(!r)return
|
||||
const {ctx,w,h} = r; const data = [32,30,28,29,27,28,30,31,28,27,29,28]
|
||||
const max=Math.max(...data,32), min=Math.min(...data,24), range=max-min||1, pad=30
|
||||
for(let i=1;i<=4;i++){ctx.strokeStyle='#eee';ctx.beginPath();ctx.moveTo(pad,(h/5)*i);ctx.lineTo(w-10,(h/5)*i);ctx.stroke()}
|
||||
const avgY=h-((28-min)/range)*(h-40)
|
||||
ctx.strokeStyle='#FFD8A8';ctx.lineWidth=1.5;ctx.setLineDash([5,3]);ctx.beginPath();ctx.moveTo(pad,avgY);ctx.lineTo(w-10,avgY);ctx.stroke();ctx.setLineDash([])
|
||||
const step=(w-pad-10)/(data.length-1)
|
||||
ctx.strokeStyle='#E893A7';ctx.lineWidth=2.5;ctx.beginPath()
|
||||
data.forEach((v,i)=>{const x=pad+i*step,y=h-40-((v-min)/range)*(h-40);i===0?ctx.moveTo(x,y):ctx.lineTo(x,y)});ctx.stroke()
|
||||
data.forEach((v,i)=>{const x=pad+i*step,y=h-40-((v-min)/range)*(h-40);ctx.fillStyle=i===data.length-1?'#D4738A':'#E893A7';ctx.beginPath();ctx.arc(x,y,i===data.length-1?5:3,0,2*Math.PI);ctx.fill()})
|
||||
}
|
||||
|
||||
function drawBar() {
|
||||
const r = setupCanvas(barRef); if(!r)return
|
||||
const {ctx,w,h} = r; const data = [5,6,4,7,5,5,6,4,7,5,6,5]; const max = Math.max(...data,7)
|
||||
data.forEach((v,i)=>{const bw=(w-40)/data.length*0.6;const x=20+(w-40)/data.length*i+(w-40)/data.length*0.2;const bh=(v/max)*(h-50);const y=h-30-bh;ctx.fillStyle=`hsl(348,${60+v*5}%,${55+v*3}%)`;ctx.beginPath();ctx.roundRect(x,y,bw,bh,4);ctx.fill()})
|
||||
const avg=data.reduce((a,b)=>a+b,0)/data.length
|
||||
ctx.strokeStyle='#C4A0D9';ctx.lineWidth=1.5;ctx.setLineDash([4,3]);ctx.beginPath();ctx.moveTo(20,h-30-(avg/max)*(h-50));ctx.lineTo(w-10,h-30-(avg/max)*(h-50));ctx.stroke()
|
||||
}
|
||||
|
||||
function drawRadar() {
|
||||
const r = setupCanvas(radarRef); if(!r)return
|
||||
const {ctx,w,h}=r; const cx=w/2,cy=h/2,radius=70
|
||||
const labels=['腹痛','头痛','疲劳','腰酸','失眠','长痘'], vals=[8,3,12,5,2,4]; const max=Math.max(...vals,1)
|
||||
for(let ring=1;ring<=5;ring++){ctx.strokeStyle='#eee';ctx.beginPath();labels.forEach((_,i)=>{const a=(Math.PI*2/labels.length)*i-Math.PI/2;const r2=(radius/5)*ring;const x=cx+r2*Math.cos(a),y=cy+r2*Math.sin(a);i===0?ctx.moveTo(x,y):ctx.lineTo(x,y)});ctx.closePath();ctx.stroke()}
|
||||
ctx.fillStyle='rgba(196,160,217,0.3)';ctx.strokeStyle='#C4A0D9';ctx.lineWidth=2;ctx.beginPath()
|
||||
vals.forEach((v,i)=>{const a=(Math.PI*2/labels.length)*i-Math.PI/2;const r2=(v/max)*radius;const x=cx+r2*Math.cos(a),y=cy+r2*Math.sin(a);i===0?ctx.moveTo(x,y):ctx.lineTo(x,y)})
|
||||
ctx.closePath();ctx.fill();ctx.stroke()
|
||||
ctx.fillStyle='#666';ctx.font='11px sans-serif';ctx.textAlign='center';
|
||||
labels.forEach((l,i)=>{const a=(Math.PI*2/labels.length)*i-Math.PI/2;ctx.fillText(`${l}(${vals[i]})`,cx+(radius+18)*Math.cos(a),cy+(radius+18)*Math.sin(a))})
|
||||
}
|
||||
|
||||
function drawBubble() {
|
||||
const r = setupCanvas(bubbleRef); if(!r)return
|
||||
const {ctx,w,h}=r; const moodVal={'很好':5,'不错':4,'一般':3,'低落':2,'烦躁':1}; const moodColor={'很好':'#A8D5BA','不错':'#A8C5E8','一般':'#F5D6A8','低落':'#C4A0D9','烦躁':'#FFC9C9'}
|
||||
const daily = Mock.generateDaily(); const days = Object.entries(daily).slice(0,30)
|
||||
days.forEach(([_,rec],i)=>{const x=25+(i/days.length)*(w-35);const v=moodVal[rec.mood]||3;const y=h-25-(v/6)*(h-50);const size={'无':8,'少量':12,'中等':16,'大量':22}[rec.flowLevel]||8;ctx.globalAlpha=0.7;ctx.fillStyle=moodColor[rec.mood]||'#ccc';ctx.beginPath();ctx.arc(x,y,size,0,2*Math.PI);ctx.fill()})
|
||||
ctx.globalAlpha=1
|
||||
}
|
||||
|
||||
function drawBox() {
|
||||
const r = setupCanvas(boxRef); if(!r)return
|
||||
const {ctx,w,h}=r; const lvls=['无','少量','中等','大量']; const colors=['#ddd','#FCE4EC','#E893A7','#D4738A']; const daily=Mock.generateDaily()
|
||||
lvls.forEach((l,i)=>{const cnt=Object.values(daily).filter(r=>r.flowLevel===l).length;const cx=40+i*65;const bh=Math.min(cnt*8,h-50);ctx.fillStyle=colors[i];ctx.beginPath();ctx.roundRect(cx-15,h-25-bh,30,bh,4);ctx.fill();ctx.fillStyle='#999';ctx.font='bold 16px sans-serif';ctx.textAlign='center';ctx.fillText(`${cnt}`,cx,h-25-bh-6);ctx.fillStyle='#666';ctx.font='12px sans-serif';ctx.fillText(l,cx,h-5)})
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.stats-row { display:flex; align-items:center; justify-content:center; gap:8px; }
|
||||
.stats-item { flex:1; text-align:center; }
|
||||
.stats-divider { width:1px; height:40px; background:#EEE; }
|
||||
.tabs { display:flex; gap:6px; overflow-x:auto; }
|
||||
.tab { display:inline-block; padding:6px 14px; margin:2px 4px; border-radius:16px; font-size:13px; color:#999; background:#F5F5F5; cursor:pointer; white-space:nowrap; }
|
||||
.tab.active { background:#FCE4EC; color:#E893A7; font-weight:500; }
|
||||
.chart-canvas { width:100%; height:220px; margin:8px 0; }
|
||||
.chart-summary { text-align:center; font-size:12px; color:#999; padding-top:4px; border-top:1px solid #F0F0F0; }
|
||||
.heatmap { display:flex; flex-wrap:wrap; gap:2px; padding:8px 0; }
|
||||
.hrow { display:flex; gap:2px; }
|
||||
.hcell { width:12px; height:12px; border-radius:2px; cursor:pointer; }
|
||||
.heat-legend { display:flex; align-items:center; gap:4px; justify-content:center; font-size:11px; color:#999; margin-top:4px; }
|
||||
.hl { width:12px; height:12px; border-radius:2px; }
|
||||
.flow-dist { display:flex; gap:8px; margin-top:8px; }
|
||||
.fi { flex:1; text-align:center; padding:8px 0; background:#FAFAFA; border-radius:10px; }
|
||||
.fi-l { font-size:13px; font-weight:500; }
|
||||
.fi-c { font-size:18px; font-weight:700; color:#E893A7; }
|
||||
.fi-p { font-size:11px; color:#999; }
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="page-scroll">
|
||||
<div class="month-nav">
|
||||
<span class="nav-btn" @click="prevMonth">‹</span>
|
||||
<span class="month-title">{{year}}年{{month}}月</span>
|
||||
<span class="nav-btn" @click="nextMonth">›</span>
|
||||
</div>
|
||||
<div class="weekdays">
|
||||
<span v-for="d in weekdays" :key="d" class="wd" :class="{weekend: d==='六'||d==='日'}">{{d}}</span>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div v-for="(day, i) in days" :key="i"
|
||||
class="cell" :class="{other:!day.isCurrentMonth, today:day.isToday, period:day.isPeriod}"
|
||||
@click="selectDay(day)">
|
||||
<span class="num">{{day.day}}</span>
|
||||
<span v-if="day.isPeriod" class="dot"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span class="leg-item"><span class="leg-dot" style="background:#E893A7"></span>经期</span>
|
||||
<span class="leg-item"><span class="leg-dot" style="background:#C4A0D9"></span>排卵窗口</span>
|
||||
</div>
|
||||
<div class="card" v-if="selDate">
|
||||
<div class="detail-hdr"><span class="detail-date">{{selLabel}}</span><span class="detail-phase">{{selPhase}}</span></div>
|
||||
<div v-if="selRecord">{{selRecord.flowLevel}} · {{selRecord.mood}} · {{selRecord.symptoms?.join('、')}}</div>
|
||||
<div v-else style="color:#ccc">暂无记录</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import * as Util from '@/utils/util.js'
|
||||
import * as Mock from '@/utils/mock.js'
|
||||
|
||||
const year = ref(2026), month = ref(5), weekdays = ['一','二','三','四','五','六','日']
|
||||
const days = ref([]), selDate = ref(null), selLabel = ref(''), selPhase = ref(''), selRecord = ref(null)
|
||||
|
||||
onMounted(() => { const d = new Date(); year.value=d.getFullYear(); month.value=d.getMonth()+1; build() })
|
||||
function build() {
|
||||
const grid = Util.generateCalendarGrid(year.value, month.value)
|
||||
const today = Util.today(); const daily = Mock.generateDaily(); const pd = Object.keys(daily).filter(k=>daily[k].flowLevel!=='无')
|
||||
days.value = grid.map(d => {
|
||||
if (!d.isCurrentMonth) return {...d, dateStr:'', isToday:false, isPeriod:false}
|
||||
const ds = Util.formatDate(new Date(year.value, month.value-1, d.day))
|
||||
return {...d, dateStr: ds, isToday: ds===today, isPeriod: pd.includes(ds)}
|
||||
})
|
||||
}
|
||||
function prevMonth() { if(month.value===1){year.value--;month.value=12}else month.value--; build() }
|
||||
function nextMonth() { if(month.value===12){year.value++;month.value=1}else month.value++; build() }
|
||||
function selectDay(day) {
|
||||
if (!day.dateStr) return; selDate.value = day.dateStr
|
||||
const d = new Date(day.dateStr); selLabel.value = `${d.getFullYear()}年${d.getMonth()+1}月${d.getDate()}日`
|
||||
const daily = Mock.generateDaily(); selRecord.value = daily[day.dateStr] || null
|
||||
selPhase.value = selRecord.value?.flowLevel !== '无' ? '经期' : '安全期'
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.month-nav { display:flex; align-items:center; justify-content:center; padding:16px; gap:20px; }
|
||||
.nav-btn { font-size:28px; color:#E893A7; cursor:pointer; padding:0 8px; }
|
||||
.month-title { font-size:18px; font-weight:600; min-width:120px; text-align:center; }
|
||||
.weekdays { display:flex; padding:0 12px; }
|
||||
.wd { flex:1; text-align:center; font-size:12px; color:#999; padding:6px 0; }
|
||||
.wd.weekend { color:#E893A7; }
|
||||
.grid { display:flex; flex-wrap:wrap; padding:0 8px; }
|
||||
.cell { width:calc(100%/7); aspect-ratio:1; display:flex; flex-direction:column; align-items:center; justify-content:center; position:relative; font-size:14px; cursor:pointer; }
|
||||
.cell.other .num { color:#ddd; }
|
||||
.cell.today .num { background:#E893A7; color:#fff; border-radius:50%; width:28px; height:28px; display:flex; align-items:center; justify-content:center; font-weight:600; }
|
||||
.cell.period .num { color:#E893A7; font-weight:500; }
|
||||
.dot { width:6px; height:6px; background:#E893A7; border-radius:50%; position:absolute; bottom:6px; }
|
||||
.legend { display:flex; gap:16px; padding:12px 16px; font-size:12px; color:#999; }
|
||||
.leg-item { display:flex; align-items:center; gap:4px; }
|
||||
.leg-dot { width:8px; height:8px; border-radius:50%; }
|
||||
.detail-hdr { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; }
|
||||
.detail-date { font-size:16px; font-weight:600; }
|
||||
.detail-phase { font-size:12px; color:#E893A7; background:#FCE4EC; padding:2px 10px; border-radius:10px; }
|
||||
</style>
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="page-scroll">
|
||||
<div class="header">
|
||||
<div class="greeting">{{greeting}},小雅</div>
|
||||
<div class="date">{{todayDisplay}}</div>
|
||||
</div>
|
||||
<StatusCard :cycleDay="cycleDay" :phase="phase.phase" :phaseColor="phase.color" :startDate="curCycle.startDate" :periodLength="curCycle.periodLength" />
|
||||
<CycleBar :currentDay="cycleDay" :cycleLength="28" :currentPhase="phase.phase" :phaseColor="phase.color" />
|
||||
<div class="card" v-if="predictionDate">
|
||||
<div class="pred-header">📅 预计下次</div>
|
||||
<div class="pred-body"><span class="pred-date">{{predictionDate}}</span><span class="pred-days">还有{{daysRemaining}}天</span></div>
|
||||
</div>
|
||||
<QuickActions @record="goRecord" @log="goRecord" />
|
||||
<div class="card">
|
||||
<div class="section-title">近期周期趋势</div>
|
||||
<canvas ref="miniChartRef" class="mini-chart"></canvas>
|
||||
<div class="trend-stats">
|
||||
<div class="ts-item"><div class="stat-number">28</div><div class="stat-label">平均周期(天)</div></div>
|
||||
<div class="ts-item"><div class="stat-number">5</div><div class="stat-label">平均经期(天)</div></div>
|
||||
<div class="ts-item"><div class="stat-number">12</div><div class="stat-label">记录周期</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import StatusCard from '@/components/StatusCard.vue'
|
||||
import CycleBar from '@/components/CycleBar.vue'
|
||||
import QuickActions from '@/components/QuickActions.vue'
|
||||
import * as Util from '@/utils/util.js'
|
||||
import * as Mock from '@/utils/mock.js'
|
||||
|
||||
const router = useRouter()
|
||||
const miniChartRef = ref(null)
|
||||
const greeting = ref(Util.getGreeting())
|
||||
const todayDisplay = ref('')
|
||||
const cycleDay = ref(1)
|
||||
const curCycle = ref(Mock.getCurrentCycle())
|
||||
const phase = ref({ phase: '--', color: '#ccc' })
|
||||
const predictionDate = ref('')
|
||||
const daysRemaining = ref(0)
|
||||
|
||||
onMounted(() => {
|
||||
const c = Mock.getCurrentCycle()
|
||||
const today = new Date()
|
||||
const cd = Math.floor((today - new Date(c.startDate)) / (1000*60*60*24)) + 1
|
||||
const next = new Date(new Date(c.startDate)); next.setDate(next.getDate() + c.cycleLength)
|
||||
cycleDay.value = cd > 0 ? cd : 1
|
||||
curCycle.value = c
|
||||
phase.value = Util.getCyclePhase(c.startDate, cycleDay.value)
|
||||
predictionDate.value = Util.formatDateCN(next)
|
||||
daysRemaining.value = Math.round((next - today) / (1000*60*60*24))
|
||||
todayDisplay.value = `${today.getFullYear()}年${today.getMonth()+1}月${today.getDate()}日`
|
||||
nextTick(drawMiniChart)
|
||||
})
|
||||
|
||||
function drawMiniChart() {
|
||||
const canvas = miniChartRef.value
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
const rect = canvas.parentElement.getBoundingClientRect()
|
||||
canvas.width = rect.width * 2; canvas.height = 120 * 2
|
||||
ctx.scale(2, 2)
|
||||
const w = rect.width, h = 120
|
||||
const data = [32,30,28,29,27,28,30,31,28,27,29,28]
|
||||
const max = Math.max(...data, 32), min = Math.min(...data, 24), range = max - min || 1
|
||||
ctx.strokeStyle = '#F0F0F0'; ctx.lineWidth = 1
|
||||
for (let i = 1; i < 4; i++) { const y = h - (h/4)*i; ctx.beginPath(); ctx.moveTo(20,y); ctx.lineTo(w-10,y); ctx.stroke() }
|
||||
const avgY = h - ((28-min)/range)*(h-20)
|
||||
ctx.strokeStyle = '#FFD8A8'; ctx.lineWidth = 1; ctx.setLineDash([4,2]); ctx.beginPath(); ctx.moveTo(20,avgY); ctx.lineTo(w-10,avgY); ctx.stroke(); ctx.setLineDash([])
|
||||
const stepX = (w-30)/(data.length-1)
|
||||
ctx.strokeStyle = '#E893A7'; ctx.lineWidth = 2; ctx.beginPath()
|
||||
data.forEach((v,i) => { const x = 20+i*stepX, y = h-((v-min)/range)*(h-20); i===0?ctx.moveTo(x,y):ctx.lineTo(x,y) })
|
||||
ctx.stroke()
|
||||
data.forEach((v,i) => { const x = 20+i*stepX, y = h-((v-min)/range)*(h-20); ctx.fillStyle = i===data.length-1?'#D4738A':'#E893A7'; ctx.beginPath(); ctx.arc(x,y,i===data.length-1?5:3,0,2*Math.PI); ctx.fill() })
|
||||
}
|
||||
|
||||
function goRecord() { router.push('/record') }
|
||||
</script>
|
||||
<style scoped>
|
||||
.header { padding: 20px 16px 8px; }
|
||||
.greeting { font-size: 18px; font-weight: 600; }
|
||||
.date { font-size: 13px; color: #999; margin-top: 2px; }
|
||||
.pred-header { font-size: 13px; color: #999; margin-bottom: 4px; }
|
||||
.pred-body { display: flex; align-items: baseline; gap: 12px; }
|
||||
.pred-date { font-size: 20px; font-weight: 700; }
|
||||
.pred-days { font-size: 14px; color: #E893A7; }
|
||||
.mini-chart { width: 100%; height: 120px; margin: 8px 0; }
|
||||
.trend-stats { display: flex; justify-content: space-around; padding-top: 8px; border-top: 1px solid #F0F0F0; }
|
||||
.ts-item { text-align: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="page-scroll">
|
||||
<div class="rec-date">{{recordDate}}</div>
|
||||
<div class="card">
|
||||
<div class="section-title">流量程度</div>
|
||||
<div class="flow-row">
|
||||
<div v-for="(l,i) in flowLevels" :key="l" class="fbtn" :class="{active: flow===l}" @click="flow=l">
|
||||
<span class="ficon">{{icons[i]}}</span><span>{{l}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="section-title">症状(可多选)</div>
|
||||
<div class="sym-grid">
|
||||
<span v-for="s in symptoms" :key="s" class="tag" :class="{'tag-active': syms.includes(s)}" @click="toggle(s)">{{s}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="section-title">今日心情</div>
|
||||
<div class="mood-row">
|
||||
<div v-for="(m,i) in moods" :key="m" class="mbtn" :class="{active: mood===m}" @click="mood=m">
|
||||
<span class="memoji">{{emojis[i]}}</span><span>{{m}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="section-title">备注</div>
|
||||
<textarea class="note-input" placeholder="写点今天的感受..." v-model="notes" maxlength="200"></textarea>
|
||||
</div>
|
||||
<div style="padding:16px"><button class="btn-primary" @click="onSave">保存记录</button></div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import * as Util from '@/utils/util.js'
|
||||
|
||||
const recordDate = ref('')
|
||||
const flowLevels = ['无','少量','中等','大量']; const icons = ['○','·','~','~~~']
|
||||
const flow = ref('无'); const symptoms = Util.SYMPTOMS; const syms = ref([])
|
||||
const moods = Util.MOODS; const emojis = ['😊','🙂','😐','😢','😤']; const mood = ref(''); const notes = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
const d = new Date(); recordDate.value = `${d.getFullYear()}年${d.getMonth()+1}月${d.getDate()}日`
|
||||
const saved = JSON.parse(localStorage.getItem('last_daily') || '{}')
|
||||
if (saved.date === Util.today()) { flow.value=saved.flowLevel||'无'; syms.value=saved.symptoms||[]; mood.value=saved.mood||''; notes.value=saved.notes||'' }
|
||||
})
|
||||
|
||||
function toggle(s) { const i=syms.value.indexOf(s); i>-1?syms.value.splice(i,1):syms.value.push(s) }
|
||||
function onSave() {
|
||||
const r = { date:Util.today(), flowLevel:flow.value, symptoms:[...syms.value], mood:mood.value, notes:notes.value }
|
||||
localStorage.setItem('last_daily', JSON.stringify(r))
|
||||
alert('保存成功 🌸')
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.rec-date { text-align:center; font-size:14px; color:#999; padding:16px 0 4px; }
|
||||
.flow-row { display:flex; gap:8px; }
|
||||
.fbtn { flex:1; display:flex; flex-direction:column; align-items:center; padding:12px 0; border-radius:12px; background:#F5F5F5; color:#999; font-size:13px; border:2px solid transparent; cursor:pointer; }
|
||||
.fbtn.active { background:#FCE4EC; border-color:#E893A7; color:#E893A7; font-weight:500; }
|
||||
.ficon { font-size:22px; margin-bottom:4px; }
|
||||
.sym-grid { display:flex; flex-wrap:wrap; gap:6px; }
|
||||
.mood-row { display:flex; gap:8px; }
|
||||
.mbtn { flex:1; display:flex; flex-direction:column; align-items:center; padding:10px 0; border-radius:12px; background:#F5F5F5; border:2px solid transparent; cursor:pointer; }
|
||||
.mbtn.active { background:#F0E6F6; border-color:#C4A0D9; }
|
||||
.memoji { font-size:24px; margin-bottom:4px; }
|
||||
.note-input { width:100%; min-height:80px; background:#FAFAFA; border:1px solid #EEE; border-radius:10px; padding:12px; font-size:14px; box-sizing:border-box; font-family:inherit; resize:vertical; }
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
export function generateCycles(count = 12) {
|
||||
const cycles = []; let cur = new Date('2025-06-10')
|
||||
for (let i = 0; i < count; i++) {
|
||||
const cl = Math.round(27 + Math.random() * 4); const pl = Math.round(4 + Math.random() * 3)
|
||||
const s = new Date(cur); const e = new Date(cur); e.setDate(e.getDate() + pl - 1)
|
||||
cycles.push({ id: `c${i}`, startDate: s.toISOString().split('T')[0], endDate: e.toISOString().split('T')[0], periodLength: pl, cycleLength: cl })
|
||||
cur.setDate(cur.getDate() + cl)
|
||||
}
|
||||
return cycles
|
||||
}
|
||||
export function generateDaily() {
|
||||
const r = {}; const t = new Date()
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const d = new Date(t); d.setDate(d.getDate() - i)
|
||||
const ds = d.toISOString().split('T')[0]; const dm = d.getDate()
|
||||
let flow = '无'; if (dm >= 21 && dm <= 23) flow = ['少量','中等','大量'][Math.floor(Math.random()*3)]
|
||||
const sym = []; if (flow !== '无') sym.push(['腹痛','疲劳','腰酸'][Math.floor(Math.random()*3)])
|
||||
if (Math.random() < 0.2) sym.push('头痛')
|
||||
r[ds] = { date: ds, flowLevel: flow, symptoms: sym, mood: ['很好','不错','一般','低落','烦躁'][Math.floor(Math.random()*5)], notes: '' }
|
||||
}
|
||||
return r
|
||||
}
|
||||
export function getCurrentCycle() {
|
||||
return { id: 'current', startDate: '2026-05-19', endDate: '2026-05-23', periodLength: 5, cycleLength: 28 }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export function formatDate(date) {
|
||||
const d = new Date(date); const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
export function formatDateCN(date) {
|
||||
const d = new Date(date); return `${d.getMonth() + 1}月${d.getDate()}日`
|
||||
}
|
||||
export function today() { return formatDate(new Date()) }
|
||||
export function daysBetween(d1, d2) {
|
||||
return Math.round((new Date(d2) - new Date(d1)) / (1000*60*60*24))
|
||||
}
|
||||
export function getCyclePhase(startDate, dayIndex) {
|
||||
if (dayIndex <= 5) return { phase: '经期', color: '#E893A7' }
|
||||
if (dayIndex <= 13) return { phase: '卵泡期', color: '#F0D9E0' }
|
||||
if (dayIndex <= 15) return { phase: '排卵期', color: '#C4A0D9' }
|
||||
return { phase: '黄体期', color: '#C3FAE8' }
|
||||
}
|
||||
export function generateCalendarGrid(year, month) {
|
||||
const firstDay = new Date(year, month - 1, 1).getDay()
|
||||
const daysInMonth = new Date(year, month, 0).getDate()
|
||||
const grid = []; const off = firstDay === 0 ? 6 : firstDay - 1
|
||||
const prev = new Date(year, month - 1, 0).getDate()
|
||||
for (let i = off - 1; i >= 0; i--) grid.push({ day: prev - i, isCurrentMonth: false })
|
||||
for (let i = 1; i <= daysInMonth; i++) grid.push({ day: i, isCurrentMonth: true })
|
||||
return grid
|
||||
}
|
||||
export function getGreeting() {
|
||||
const h = new Date().getHours()
|
||||
if (h < 6) return '夜深了'; if (h < 9) return '早上好'; if (h < 12) return '上午好'
|
||||
if (h < 14) return '中午好'; if (h < 18) return '下午好'; return '晚上好'
|
||||
}
|
||||
export const SYMPTOMS = ['腹痛','头痛','疲劳','乳房胀痛','腰酸','恶心','食欲变化','失眠','水肿','长痘']
|
||||
export const MOODS = ['很好','不错','一般','低落','烦躁']
|
||||
export const FLOW_LEVELS = ['无','少量','中等','大量']
|
||||
Reference in New Issue
Block a user