Files
digital-psychology/apps/user-h5/src/pages/GrowthPlanPage.vue
T
jackyu66gitandCursor 89756f65b4 feat(ECR-012–016): 合规、题库、时辰刷新、头像、MBTI OEJTS 与埋点
落地输入合规、探索题库、报告日/时辰刷新、账号头像、OEJTS 量表,并补齐 H5 埋点与 Admin 漏斗;同步 ESS 工件、切至自建 Git、清理 GitHub Actions。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 01:27:58 +08:00

213 lines
5.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<PageShell>
<template #hero>
<div class="head">
<HomeToolIcon name="growth" :size="48" />
<div>
<h1 class="title">成长计划</h1>
<p class="sub">小目标 · 每日打卡</p>
</div>
</div>
</template>
<div class="body">
<div class="create">
<label class="lbl">新计划标题</label>
<input class="inp" v-model="title" maxlength="40" placeholder="例如:每晚 11 点前放下手机" />
<label class="lbl">焦点可选</label>
<input class="inp" v-model="focus" maxlength="40" placeholder="例如:保护睡眠与情绪" />
<button class="cta" type="button" :disabled="saving || !title.trim()" @click="create">创建计划</button>
<p v-if="err" class="err">{{ err }}</p>
</div>
<p v-if="loading" class="hint">加载中</p>
<div v-else class="plans">
<div v-for="p in plans" :key="p.id" class="plan">
<strong>{{ p.title }}</strong>
<p v-if="p.focus" class="focus">{{ p.focus }}</p>
<button type="button" class="check" :disabled="checking === p.id" @click="checkin(p.id)">
{{ checking === p.id ? '记录中…' : '今日打卡' }}
</button>
<ul v-if="checkins[p.id]?.length" class="cks">
<li v-for="c in checkins[p.id]" :key="c.id">{{ c.day }}{{ c.note ? ' · ' + c.note : '' }}</li>
</ul>
</div>
<p v-if="!plans.length" class="hint">还没有计划先创建一个小目标吧</p>
</div>
</div>
</PageShell>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { validateUserText } from '@yuxingu/utils'
import { api } from '../api/client'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import { ensureAccount } from '../lib/authSession'
import { AnalyticsEvent, track } from '../lib/analytics'
const router = useRouter()
const route = useRoute()
const plans = ref<{ id: string; title: string; focus: string }[]>([])
const checkins = ref<Record<string, { id: string; day: string; note?: string }[]>>({})
const title = ref('')
const focus = ref('')
const loading = ref(true)
const saving = ref(false)
const checking = ref('')
const err = ref('')
async function load(opts?: { trackView?: boolean }) {
loading.value = true
if (!(await ensureAccount(router, route.fullPath))) {
loading.value = false
return
}
try {
const res = await api.listGrowthPlans()
plans.value = res.items || []
for (const p of plans.value) {
const ck = await api.listGrowthCheckins(p.id)
checkins.value[p.id] = ck.items || []
}
} catch (e) {
err.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
if (opts?.trackView) track(AnalyticsEvent.GrowthPlanViewed)
}
}
async function create() {
saving.value = true
err.value = ''
try {
const titleCheck = validateUserText('title', title.value)
if (!titleCheck.ok) throw new Error(titleCheck.message)
const focusCheck = validateUserText('focus', focus.value)
if (!focusCheck.ok) throw new Error(focusCheck.message)
await api.createGrowthPlan({
title: titleCheck.value,
focus: focusCheck.value || undefined,
})
title.value = ''
focus.value = ''
track(AnalyticsEvent.GrowthPlanCreated)
await load()
} catch (e) {
err.value = e instanceof Error ? e.message : '创建失败'
} finally {
saving.value = false
}
}
async function checkin(id: string) {
checking.value = id
err.value = ''
try {
await api.createGrowthCheckin(id, {})
const ck = await api.listGrowthCheckins(id)
checkins.value[id] = ck.items || []
track(AnalyticsEvent.GrowthPlanCheckin)
} catch (e) {
err.value = e instanceof Error ? e.message : '打卡失败'
} finally {
checking.value = ''
}
}
onMounted(() => {
void load({ trackView: true })
})
</script>
<style scoped>
.head {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
padding-bottom: 8px;
}
.title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
letter-spacing: 0.06em;
}
.sub {
font-size: 12px;
color: rgba(0, 0, 0, 0.38);
margin-top: 2px;
}
.body { padding: 8px 16px 0; }
.create {
padding: 16px;
background: #fafafa;
border-radius: var(--radius-xl);
margin-bottom: 14px;
}
.lbl {
display: block;
font-size: 12px;
color: #999;
margin: 8px 0 6px;
}
.lbl:first-child { margin-top: 0; }
.inp {
width: 100%;
padding: 11px 12px;
border-radius: 12px;
border: 1.5px solid #eee;
background: #fff;
font-size: 14px;
font-family: inherit;
outline: none;
}
.cta {
margin-top: 14px;
width: 100%;
padding: 12px;
border: none;
border-radius: 22px;
color: #fff;
font-size: 14px;
font-weight: 600;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
}
.cta:disabled { opacity: 0.45; }
.plans { display: flex; flex-direction: column; gap: 10px; }
.plan {
padding: 16px;
background: #fff;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
border: 1px solid #f5f0ed;
}
.plan strong { font-size: 15px; color: #222; }
.focus { font-size: 12px; color: #888; margin: 6px 0 10px; }
.check {
padding: 8px 14px;
border-radius: 18px;
border: 1.5px solid #f5c4bc;
background: #fff;
color: var(--color-primary);
font-size: 13px;
font-weight: 600;
}
.check:disabled { opacity: 0.5; }
.cks {
list-style: none;
margin: 10px 0 0;
padding: 0;
font-size: 12px;
color: #999;
}
.cks li { padding: 4px 0; }
.hint { font-size: 13px; color: #999; text-align: center; padding: 16px; }
.err { color: var(--color-primary); font-size: 12px; margin-top: 8px; }
</style>