feat: initialize Evergreen Health mobile app
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# 后端开发地址;通过 Vite 代理访问,避免浏览器跨域。
|
||||
VITE_API_BASE_URL=http://localhost:5005
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
*.local
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0c5045" />
|
||||
<title>常青树健康管理</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "evergreen-health-mobile",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "1.7.9",
|
||||
"pinia": "2.3.1",
|
||||
"vant": "4.9.21",
|
||||
"vue": "3.5.13",
|
||||
"vue-router": "4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "5.2.1",
|
||||
"typescript": "5.7.3",
|
||||
"vite": "5.4.14",
|
||||
"vue-tsc": "2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
Generated
+1189
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import 'vant/lib/index.css'
|
||||
import './styles.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
createApp(App).use(createPinia()).use(router).mount('#app')
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import AuthView from './views/AuthView.vue'
|
||||
import OnboardingView from './views/OnboardingView.vue'
|
||||
import DashboardView from './views/DashboardView.vue'
|
||||
import { hasToken } from './services/auth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', redirect: '/dashboard' },
|
||||
{ path: '/auth', component: AuthView, meta: { guest: true } },
|
||||
{ path: '/onboarding', component: OnboardingView },
|
||||
{ path: '/dashboard', component: DashboardView }
|
||||
]
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (!to.meta.guest && !hasToken()) return '/auth'
|
||||
if (to.meta.guest && hasToken()) return '/dashboard'
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,20 @@
|
||||
const ACCESS_TOKEN_KEY = 'evergreen-health-access-token'
|
||||
const REFRESH_TOKEN_KEY = 'evergreen-health-refresh-token'
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem(ACCESS_TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function hasToken() {
|
||||
return Boolean(getToken())
|
||||
}
|
||||
|
||||
export function saveTokens(accessToken: string, refreshToken: string) {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken)
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken)
|
||||
}
|
||||
|
||||
export function clearTokens() {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY)
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import http from './http'
|
||||
|
||||
export type HealthUser = {
|
||||
id: number
|
||||
phone: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
export type AuthOutput = {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
user: HealthUser
|
||||
}
|
||||
|
||||
export type HealthProfile = {
|
||||
birthday?: string
|
||||
gender?: string
|
||||
heightCm?: number
|
||||
weightKg?: number
|
||||
averageSleepHours?: number
|
||||
dailyExerciseMinutes?: number
|
||||
dietPreference?: string
|
||||
healthGoal?: string
|
||||
}
|
||||
|
||||
export type PlanItem = {
|
||||
moduleCode: string
|
||||
title: string
|
||||
target: string
|
||||
advice: string
|
||||
}
|
||||
|
||||
export type HealthPlan = {
|
||||
id: number
|
||||
version: number
|
||||
title: string
|
||||
summary: string
|
||||
createTime: string
|
||||
items: PlanItem[]
|
||||
}
|
||||
|
||||
export type Dashboard = {
|
||||
user: HealthUser
|
||||
profile?: HealthProfile
|
||||
selectedModules: string[]
|
||||
currentPlan?: HealthPlan
|
||||
}
|
||||
|
||||
export const healthApi = {
|
||||
register: (payload: { phone: string; password: string; displayName: string }) =>
|
||||
http.post<never, AuthOutput>('/api/health/auth/register', payload),
|
||||
login: (payload: { phone: string; password: string }) =>
|
||||
http.post<never, AuthOutput>('/api/health/auth/login', payload),
|
||||
dashboard: () => http.get<never, Dashboard>('/api/health/dashboard'),
|
||||
saveProfile: (payload: HealthProfile) =>
|
||||
http.put<never, HealthProfile>('/api/health/profile', payload),
|
||||
saveModules: (moduleCodes: string[]) =>
|
||||
http.put<never, string[]>('/api/health/modules', { moduleCodes }),
|
||||
generatePlan: () => http.post<never, HealthPlan>('/api/health/plans/generate')
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import axios from 'axios'
|
||||
import { clearTokens, getToken } from './auth'
|
||||
|
||||
type UnifiedResult<T> = {
|
||||
type?: string
|
||||
message?: string
|
||||
result?: T
|
||||
errors?: unknown
|
||||
}
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: '/',
|
||||
timeout: 15000
|
||||
})
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = getToken()
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
http.interceptors.response.use(
|
||||
((response: any) => {
|
||||
const body = response.data as UnifiedResult<unknown>
|
||||
if (body && typeof body === 'object' && 'result' in body) {
|
||||
if (body.type && body.type !== 'success') {
|
||||
return Promise.reject(new Error(body.message || '请求失败'))
|
||||
}
|
||||
return body.result
|
||||
}
|
||||
return body
|
||||
}) as any,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) clearTokens()
|
||||
const body = error.response?.data as UnifiedResult<unknown> | undefined
|
||||
const message = body?.message || error.message || '网络请求失败'
|
||||
return Promise.reject(new Error(message))
|
||||
}
|
||||
)
|
||||
|
||||
export default http
|
||||
+724
@@ -0,0 +1,724 @@
|
||||
:root {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "WenQuanYi Micro Hei", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
color: #17322a;
|
||||
background-color: #edebe4;
|
||||
|
||||
/* Brand Theme Tokens */
|
||||
--primary-deep: #09372d;
|
||||
--primary: #0c4d3f;
|
||||
--primary-light: #156655;
|
||||
--primary-subtle: #e8f2ef;
|
||||
--accent-gold: #c89547;
|
||||
--accent-light: #fdf6ea;
|
||||
--accent-warm: #e3b063;
|
||||
|
||||
/* Neutrals */
|
||||
--bg-app: #f6f5f0;
|
||||
--card-bg: #ffffff;
|
||||
--line: rgba(12, 77, 63, 0.08);
|
||||
--line-light: rgba(12, 77, 63, 0.04);
|
||||
--text-main: #142e27;
|
||||
--text-sub: #576d65;
|
||||
--text-muted: #8d9e97;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-card: 0 4px 16px -2px rgba(9, 55, 45, 0.05), 0 2px 6px -1px rgba(9, 55, 45, 0.03);
|
||||
--shadow-float: 0 12px 32px -4px rgba(9, 55, 45, 0.12);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background-color: #edebe4;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-app);
|
||||
box-shadow: 0 0 40px rgba(0, 0, 0, 0.06);
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
button, input, textarea {
|
||||
font: inherit;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Common Typography */
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 0 0 6px;
|
||||
color: var(--accent-gold);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Page Shell Layout */
|
||||
.page-shell {
|
||||
min-height: 100vh;
|
||||
padding: 16px 16px calc(90px + env(safe-area-inset-bottom));
|
||||
background: var(--bg-app);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Card Common */
|
||||
.section-card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 20px;
|
||||
padding: 18px 16px;
|
||||
margin-bottom: 14px;
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.section-title > span {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.section-title small {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.section-title button {
|
||||
background: transparent;
|
||||
color: var(--primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Auth Page */
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-app);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.brand-panel {
|
||||
padding: calc(36px + env(safe-area-inset-top)) 24px 38px;
|
||||
color: #fff;
|
||||
background:
|
||||
radial-gradient(circle at 85% 15%, rgba(227, 176, 99, 0.24), transparent 45%),
|
||||
radial-gradient(circle at 10% 90%, rgba(21, 102, 85, 0.4), transparent 50%),
|
||||
linear-gradient(155deg, var(--primary-deep) 0%, var(--primary) 65%, #13463b 100%);
|
||||
border-radius: 0 0 32px 32px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.brand-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 12px 5px 6px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 24px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 20px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.brand-badge-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--accent-gold);
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.brand-panel h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
margin-bottom: 10px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.feature-pill-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.feature-pill-row span {
|
||||
padding: 5px 11px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 16px;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: calc(100% - 32px);
|
||||
margin: -24px auto 24px;
|
||||
padding: 22px 20px 24px;
|
||||
background: #fff;
|
||||
border-radius: 24px;
|
||||
box-shadow: var(--shadow-float);
|
||||
border: 1px solid var(--line);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.auth-tab-switch {
|
||||
display: flex;
|
||||
background: #f1efea;
|
||||
border-radius: 12px;
|
||||
padding: 3px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.auth-tab-switch button {
|
||||
flex: 1;
|
||||
padding: 9px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
border-radius: 9px;
|
||||
color: var(--text-sub);
|
||||
background: transparent;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.auth-tab-switch button.active {
|
||||
background: #ffffff;
|
||||
color: var(--primary);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.auth-card .van-field {
|
||||
margin-bottom: 12px;
|
||||
padding: 12px 14px;
|
||||
background: #f8f7f4;
|
||||
border-radius: 14px;
|
||||
border: 1px solid transparent;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.auth-card .van-field:focus-within {
|
||||
border-color: var(--primary);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.auth-card .van-button {
|
||||
margin-top: 14px;
|
||||
height: 48px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
box-shadow: 0 8px 20px -2px rgba(12, 77, 63, 0.28);
|
||||
}
|
||||
|
||||
.privacy-note {
|
||||
margin-top: 18px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Onboarding Header */
|
||||
.page-header {
|
||||
margin-bottom: 18px;
|
||||
padding: 6px 4px 0;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
margin: 4px 0 8px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: var(--text-sub);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Module Selection Grid (3 Columns) */
|
||||
.module-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.module-card {
|
||||
padding: 14px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
border: 1.5px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: #faf9f6;
|
||||
color: var(--text-main);
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.module-card.selected {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary-subtle);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.module-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 18px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.module-card.selected .module-icon {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.module-card strong {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.module-card small {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.module-card.selected small {
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.module-check {
|
||||
margin-top: 8px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
background: #eee;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.module-card.selected .module-check {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Profile Form Elements */
|
||||
.form-group-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 12px 0 8px;
|
||||
}
|
||||
|
||||
.two-column {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.custom-field-box {
|
||||
background: #f8f7f3;
|
||||
border-radius: 14px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.custom-field-box:focus-within {
|
||||
background: #fff;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.custom-field-box .van-field {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Sticky Bottom Action */
|
||||
.sticky-action {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
bottom: 0;
|
||||
padding: 12px 16px calc(14px + env(safe-area-inset-bottom));
|
||||
background: rgba(246, 245, 240, 0.92);
|
||||
backdrop-filter: blur(16px);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.sticky-action .van-button {
|
||||
height: 48px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 8px 24px -2px rgba(12, 77, 63, 0.25);
|
||||
}
|
||||
|
||||
/* Dashboard Header */
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
padding: 8px 2px 0;
|
||||
}
|
||||
|
||||
.header-meta h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.date-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
color: var(--text-sub);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 12px 5px 6px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
box-shadow: var(--shadow-card);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.user-chip:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.avatar-badge {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.user-chip-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
/* Hero Health Card */
|
||||
.hero-card {
|
||||
padding: 22px 20px;
|
||||
color: #fff;
|
||||
border-radius: 24px;
|
||||
background:
|
||||
radial-gradient(circle at 90% 10%, rgba(227, 176, 99, 0.28), transparent 45%),
|
||||
linear-gradient(145deg, var(--primary-deep) 0%, var(--primary) 70%, #155749 100%);
|
||||
box-shadow: var(--shadow-float);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.hero-top-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.hero-card h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.hero-summary {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
line-height: 1.65;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.tag-pill {
|
||||
padding: 4px 10px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hero-action-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Metrics Strip (3 Cards) */
|
||||
.metric-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 12px 10px;
|
||||
text-align: center;
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.metric-card strong {
|
||||
display: block;
|
||||
font-size: 19px;
|
||||
font-weight: 800;
|
||||
color: var(--text-main);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.metric-card em {
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
color: var(--text-sub);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.metric-desc {
|
||||
font-size: 10px;
|
||||
color: var(--accent-gold);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Actionable Plan Items */
|
||||
.plan-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 14px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.plan-item:first-of-type {
|
||||
border-top: none;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.plan-item-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 12px;
|
||||
background: var(--primary-subtle);
|
||||
color: var(--primary);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.plan-item-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.plan-item-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.plan-item-header strong {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.plan-item-target {
|
||||
font-size: 11px;
|
||||
color: var(--accent-gold);
|
||||
font-weight: 600;
|
||||
background: var(--accent-light);
|
||||
padding: 2px 7px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.plan-item-advice {
|
||||
font-size: 12px;
|
||||
color: var(--text-sub);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Smart Space / IoT Card */
|
||||
.smart-space-card {
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f7faf9 100%);
|
||||
border: 1px solid rgba(12, 77, 63, 0.12);
|
||||
}
|
||||
|
||||
.smart-scene-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.scene-icon-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.scene-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
background: #f2f5f4;
|
||||
color: var(--primary);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.scene-text strong {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.scene-text p {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.smart-badge {
|
||||
font-size: 10px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
background: #f1efea;
|
||||
color: var(--text-sub);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.medical-disclaimer {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
margin: 18px 8px 10px;
|
||||
}
|
||||
|
||||
/* Mobile Touch Optimization */
|
||||
@media (max-width: 360px) {
|
||||
.module-grid {
|
||||
gap: 6px;
|
||||
}
|
||||
.module-card {
|
||||
padding: 10px 6px;
|
||||
}
|
||||
.module-card strong {
|
||||
font-size: 12px;
|
||||
}
|
||||
.metric-card strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Button as VanButton, Field as VanField, Form as VanForm, showToast } from 'vant'
|
||||
import { healthApi } from '../services/health'
|
||||
import { saveTokens } from '../services/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const mode = ref<'login' | 'register'>('login')
|
||||
const loading = ref(false)
|
||||
const form = reactive({ phone: '', password: '', displayName: '' })
|
||||
const title = computed(() => mode.value === 'login' ? '欢迎回来' : '开启专属健康档案')
|
||||
|
||||
async function submit() {
|
||||
loading.value = true
|
||||
try {
|
||||
const output = mode.value === 'login'
|
||||
? await healthApi.login({ phone: form.phone, password: form.password })
|
||||
: await healthApi.register(form)
|
||||
saveTokens(output.accessToken, output.refreshToken)
|
||||
showToast({ type: 'success', message: mode.value === 'login' ? '登录成功' : '注册成功' })
|
||||
await router.replace(mode.value === 'login' ? '/dashboard' : '/onboarding')
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : '操作失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="auth-page">
|
||||
<section class="brand-panel">
|
||||
<div class="brand-badge">
|
||||
<span class="brand-badge-icon">常</span>
|
||||
<span>一人一方 · 康养管理</span>
|
||||
</div>
|
||||
<p class="eyebrow">EVERGREEN HEALTH</p>
|
||||
<h1>一人一方<br />健康与空间协同</h1>
|
||||
<p class="brand-copy">以睡眠为切入点,融合五感空间与动态健康处方,开启治未病日常管理。</p>
|
||||
<div class="feature-pill-row">
|
||||
<span>🌿 六维处方</span>
|
||||
<span>🌙 五感微环境</span>
|
||||
<span>📊 闭环调理</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="auth-card">
|
||||
<div class="auth-tab-switch">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: mode === 'login' }"
|
||||
@click="mode = 'login'"
|
||||
>
|
||||
账号登录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: mode === 'register' }"
|
||||
@click="mode = 'register'"
|
||||
>
|
||||
注册建档
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="auth-heading">
|
||||
<h2 style="font-size: 20px; font-weight: 700; color: #142e27; margin-bottom: 4px;">{{ title }}</h2>
|
||||
<p style="color: #72847d; font-size: 13px; margin-bottom: 16px;">
|
||||
{{ mode === 'login' ? '登录查看今日健康处方与微环境状态' : '第一步:填写手机号快速创建健康账户' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<van-form @submit="submit">
|
||||
<van-field
|
||||
v-if="mode === 'register'"
|
||||
v-model="form.displayName"
|
||||
label="您的姓名"
|
||||
placeholder="请输入您的真实姓名或称呼"
|
||||
:rules="[{ required: true, message: '请输入姓名' }]"
|
||||
/>
|
||||
<van-field
|
||||
v-model="form.phone"
|
||||
type="tel"
|
||||
label="手机号码"
|
||||
maxlength="11"
|
||||
placeholder="请输入 11 位手机号"
|
||||
:rules="[{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' }]"
|
||||
/>
|
||||
<van-field
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
label="账户密码"
|
||||
placeholder="请输入至少 8 位密码"
|
||||
:rules="[{ required: true, message: '请输入至少 8 位密码' }]"
|
||||
/>
|
||||
|
||||
<van-button
|
||||
block
|
||||
round
|
||||
color="#0c4d3f"
|
||||
native-type="submit"
|
||||
:loading="loading"
|
||||
>
|
||||
{{ mode === 'login' ? '登 录' : '创建档案并下一步' }}
|
||||
</van-button>
|
||||
</van-form>
|
||||
|
||||
<p class="privacy-note">
|
||||
🔒 数据安全说明:所有身体体征与处方数据均遵循隐私保护规范,仅用于为您生成生活方式干预建议。
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Button as VanButton, showConfirmDialog, showToast } from 'vant'
|
||||
import { clearTokens } from '../services/auth'
|
||||
import { healthApi, type Dashboard } from '../services/health'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(true)
|
||||
const generating = ref(false)
|
||||
const dashboard = ref<Dashboard>()
|
||||
const moduleNames: Record<string, { name: string; icon: string }> = {
|
||||
sleep: { name: '睡眠', icon: '🌙' },
|
||||
exercise: { name: '运动', icon: '🏃' },
|
||||
diet: { name: '饮食', icon: '🥗' }
|
||||
}
|
||||
|
||||
const greetingInfo = computed(() => {
|
||||
const hour = new Date().getHours()
|
||||
if (hour < 11) return { text: '早安', icon: '🌅' }
|
||||
if (hour < 14) return { text: '午安', icon: '☀️' }
|
||||
if (hour < 19) return { text: '下午好', icon: '🍵' }
|
||||
return { text: '晚上好', icon: '🌙' }
|
||||
})
|
||||
|
||||
const todayDateText = computed(() => {
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'short'
|
||||
}).format(new Date())
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
dashboard.value = await healthApi.dashboard()
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
generating.value = true
|
||||
try {
|
||||
await healthApi.generatePlan()
|
||||
await load()
|
||||
showToast({ type: 'success', message: '方案已生成并更新' })
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : '生成失败')
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleUserClick() {
|
||||
showConfirmDialog({
|
||||
title: '账户设置',
|
||||
message: `当前登录:${dashboard.value?.user.displayName || '健康用户'}(${dashboard.value?.user.phone || ''}),确定要退出登录吗?`,
|
||||
confirmButtonText: '退出登录',
|
||||
cancelButtonText: '取消',
|
||||
confirmButtonColor: '#c0392b'
|
||||
}).then(() => {
|
||||
clearTokens()
|
||||
router.replace('/auth')
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="page-shell dashboard-page">
|
||||
<!-- 顶部状态栏与用户头像 -->
|
||||
<header class="dashboard-header">
|
||||
<div class="header-meta">
|
||||
<span class="eyebrow">EVERGREEN · TODAY</span>
|
||||
<h1>{{ greetingInfo.icon }} {{ greetingInfo.text }},{{ dashboard?.user.displayName || '您好' }}</h1>
|
||||
<div class="date-badge">
|
||||
<span>📅 {{ todayDateText }}</span>
|
||||
<span>· 一人一方每日协同</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="user-chip" type="button" @click="handleUserClick">
|
||||
<div class="avatar-badge">
|
||||
{{ dashboard?.user.displayName?.slice(0, 1) || '常' }}
|
||||
</div>
|
||||
<span class="user-chip-name">我的账户</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- 加载态骨架 -->
|
||||
<section v-if="loading" class="section-card loading-card" style="padding: 40px 20px;">
|
||||
<div style="font-size: 24px; margin-bottom: 10px;">🌿</div>
|
||||
<p style="color: var(--text-sub); font-size: 14px;">正在同步您的今日健康方案与微环境参数…</p>
|
||||
</section>
|
||||
|
||||
<template v-else>
|
||||
<!-- 今日方案主卡片 (Hero Card) -->
|
||||
<section class="hero-card">
|
||||
<div class="hero-top-row">
|
||||
<span class="eyebrow" style="color: #f1c37a;">今日专属干预方案</span>
|
||||
<span v-if="dashboard?.currentPlan" style="font-size: 11px; padding: 2px 8px; border-radius: 10px; background: rgba(255,255,255,0.16); color: #fff;">
|
||||
V{{ dashboard.currentPlan.version }} · 方案生效中
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2>{{ dashboard?.currentPlan?.title || '尚未生成健康方案' }}</h2>
|
||||
<p class="hero-summary">
|
||||
{{ dashboard?.currentPlan?.summary || '完成基础板块选择与身体数据录入后,系统将为您量身定制每日干预方案。' }}
|
||||
</p>
|
||||
|
||||
<!-- 启用的健康板块标签 -->
|
||||
<div class="tag-row">
|
||||
<span v-for="code in dashboard?.selectedModules" :key="code" class="tag-pill">
|
||||
{{ moduleNames[code]?.icon }} {{ moduleNames[code]?.name || code }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮行 -->
|
||||
<div class="hero-action-row">
|
||||
<van-button
|
||||
v-if="!dashboard?.currentPlan"
|
||||
round
|
||||
color="#f1c37a"
|
||||
text-color="#183b34"
|
||||
style="font-weight: 700;"
|
||||
:loading="generating"
|
||||
@click="generate"
|
||||
>
|
||||
✨ 立即生成专属方案
|
||||
</van-button>
|
||||
<van-button
|
||||
v-else
|
||||
round
|
||||
size="small"
|
||||
color="rgba(255,255,255,0.18)"
|
||||
style="border: 1px solid rgba(255,255,255,0.3); color: #fff;"
|
||||
:loading="generating"
|
||||
@click="generate"
|
||||
>
|
||||
↺ 重新评估生成
|
||||
</van-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 核心指标状态栏 (3 列式卡片) -->
|
||||
<section class="metric-strip">
|
||||
<div class="metric-card">
|
||||
<span class="metric-label">🌙 睡眠目标</span>
|
||||
<strong>7–8<em>h</em></strong>
|
||||
<span class="metric-desc">建议 23:00 前</span>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span class="metric-label">🏃 活动目标</span>
|
||||
<strong>30<em>min</em></strong>
|
||||
<span class="metric-desc">中等强度</span>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span class="metric-label">🔖 方案版本</span>
|
||||
<strong>V{{ dashboard?.currentPlan?.version || 0 }}<em>版</em></strong>
|
||||
<span class="metric-desc">动态演进</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 今日六维行动建议 -->
|
||||
<section class="section-card">
|
||||
<div class="section-title">
|
||||
<span>今日执行清单</span>
|
||||
<button type="button" @click="router.push('/onboarding')">
|
||||
调整偏好 ›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="dashboard?.currentPlan?.items?.length">
|
||||
<article
|
||||
v-for="item in dashboard.currentPlan.items"
|
||||
:key="item.moduleCode"
|
||||
class="plan-item"
|
||||
>
|
||||
<div class="plan-item-icon">
|
||||
{{ item.moduleCode === 'sleep' ? '🌙' : item.moduleCode === 'exercise' ? '🏃' : '🥗' }}
|
||||
</div>
|
||||
<div class="plan-item-content">
|
||||
<div class="plan-item-header">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span class="plan-item-target">{{ item.target }}</span>
|
||||
</div>
|
||||
<p class="plan-item-advice">{{ item.advice }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else style="text-align: center; padding: 20px; color: var(--text-muted); font-size: 13px;">
|
||||
暂无执行建议,点击上方按钮即可一键生成方案。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 五感微环境空间联动卡片 -->
|
||||
<section class="section-card smart-space-card">
|
||||
<div class="section-title">
|
||||
<span>五感舒睡空间</span>
|
||||
<small class="smart-badge">⚡ Home Assistant 待接入</small>
|
||||
</div>
|
||||
|
||||
<div class="smart-scene-box">
|
||||
<div class="scene-icon-title">
|
||||
<div class="scene-icon">🛏️</div>
|
||||
<div class="scene-text">
|
||||
<strong>深度舒睡模式</strong>
|
||||
<p>预冷20℃ · 琥珀微光 · 新风正压 · 草本扩香</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
style="padding: 6px 10px; border-radius: 12px; font-size: 11px; background: #e8eae6; color: #8e9a95;"
|
||||
>
|
||||
准备就绪
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 免责与合规提示 -->
|
||||
<p class="medical-disclaimer">
|
||||
⚠️ 本建议由一人一方生活方式决策模型生成,仅用于日常健康管理与微环境优化,不构成医疗诊断或临床处方。
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- 底部固定快捷导航栏 -->
|
||||
<nav class="sticky-action" style="display: flex; gap: 8px; justify-content: space-between;">
|
||||
<van-button
|
||||
block
|
||||
round
|
||||
color="#0c4d3f"
|
||||
style="flex: 2; height: 46px; font-size: 14px;"
|
||||
@click="generate"
|
||||
:loading="generating"
|
||||
>
|
||||
✨ 刷新今日建议
|
||||
</van-button>
|
||||
<van-button
|
||||
round
|
||||
plain
|
||||
color="#0c4d3f"
|
||||
style="flex: 1; height: 46px; font-size: 14px;"
|
||||
@click="router.push('/onboarding')"
|
||||
>
|
||||
调整偏好
|
||||
</van-button>
|
||||
</nav>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Button as VanButton, Field as VanField, showToast } from 'vant'
|
||||
import { healthApi } from '../services/health'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const selected = ref<string[]>(['sleep', 'exercise', 'diet'])
|
||||
const modules = [
|
||||
{ code: 'sleep', icon: '🌙', title: '睡眠质量', copy: '环境、节律与深睡' },
|
||||
{ code: 'exercise', icon: '🏃', title: '运动习惯', copy: '日常活动与活力' },
|
||||
{ code: 'diet', icon: '🥗', title: '饮食营养', copy: '三餐热量与偏好' }
|
||||
]
|
||||
const profile = reactive({
|
||||
gender: '',
|
||||
heightCm: undefined as number | undefined,
|
||||
weightKg: undefined as number | undefined,
|
||||
averageSleepHours: undefined as number | undefined,
|
||||
dailyExerciseMinutes: undefined as number | undefined,
|
||||
dietPreference: '',
|
||||
healthGoal: ''
|
||||
})
|
||||
|
||||
function toggle(code: string) {
|
||||
selected.value = selected.value.includes(code)
|
||||
? selected.value.filter((item) => item !== code)
|
||||
: [...selected.value, code]
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!selected.value.length) {
|
||||
showToast('请至少选择一个健康板块')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await Promise.all([
|
||||
healthApi.saveModules(selected.value),
|
||||
healthApi.saveProfile(profile)
|
||||
])
|
||||
await healthApi.generatePlan()
|
||||
showToast({ type: 'success', message: '专属方案已生成' })
|
||||
await router.replace('/dashboard')
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : '保存失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="page-shell">
|
||||
<header class="page-header">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span class="eyebrow">STEP 01 · 基础建档</span>
|
||||
<span style="font-size: 11px; color: var(--accent-gold); font-weight: 600;">一人一方 · 智能生成</span>
|
||||
</div>
|
||||
<h1>今天,您最想关注什么?</h1>
|
||||
<p>选择核心关注板块并补充身体数据,系统将依此推导并生成第一版个人健康方案。</p>
|
||||
</header>
|
||||
|
||||
<!-- 1. 核心健康板块选择 -->
|
||||
<section class="section-card">
|
||||
<div class="section-title">
|
||||
<span>重点关注板块</span>
|
||||
<small>可多选,已选 {{ selected.length }}/3</small>
|
||||
</div>
|
||||
<div class="module-grid">
|
||||
<button
|
||||
v-for="item in modules"
|
||||
:key="item.code"
|
||||
class="module-card"
|
||||
:class="{ selected: selected.includes(item.code) }"
|
||||
type="button"
|
||||
@click="toggle(item.code)"
|
||||
>
|
||||
<div class="module-icon">{{ item.icon }}</div>
|
||||
<strong>{{ item.title }}</strong>
|
||||
<small>{{ item.copy }}</small>
|
||||
<span class="module-check">{{ selected.includes(item.code) ? '✓ 已选' : '+ 选择' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2. 基础体征与体能 -->
|
||||
<section class="section-card">
|
||||
<div class="section-title">
|
||||
<span>身体基线数据</span>
|
||||
<small>用于核算 BMI 与基础消耗</small>
|
||||
</div>
|
||||
|
||||
<div class="two-column">
|
||||
<div class="custom-field-box">
|
||||
<van-field
|
||||
v-model="profile.gender"
|
||||
label="性别"
|
||||
placeholder="男 / 女"
|
||||
/>
|
||||
</div>
|
||||
<div class="custom-field-box">
|
||||
<van-field
|
||||
v-model.number="profile.heightCm"
|
||||
type="number"
|
||||
label="身高"
|
||||
placeholder="例: 175"
|
||||
>
|
||||
<template #extra><span style="font-size: 12px; color: #8d9e97;">cm</span></template>
|
||||
</van-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="two-column">
|
||||
<div class="custom-field-box">
|
||||
<van-field
|
||||
v-model.number="profile.weightKg"
|
||||
type="number"
|
||||
label="体重"
|
||||
placeholder="例: 68"
|
||||
>
|
||||
<template #extra><span style="font-size: 12px; color: #8d9e97;">kg</span></template>
|
||||
</van-field>
|
||||
</div>
|
||||
<div class="custom-field-box">
|
||||
<van-field
|
||||
v-model.number="profile.averageSleepHours"
|
||||
type="number"
|
||||
label="平日睡眠"
|
||||
placeholder="例: 6.5"
|
||||
>
|
||||
<template #extra><span style="font-size: 12px; color: #8d9e97;">h</span></template>
|
||||
</van-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="custom-field-box">
|
||||
<van-field
|
||||
v-model.number="profile.dailyExerciseMinutes"
|
||||
type="number"
|
||||
label="日均活动"
|
||||
placeholder="平均每天中等活动时长"
|
||||
>
|
||||
<template #extra><span style="font-size: 12px; color: #8d9e97;">分钟/天</span></template>
|
||||
</van-field>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3. 饮食偏好与健康诉求 -->
|
||||
<section class="section-card">
|
||||
<div class="section-title">
|
||||
<span>饮食习惯与预期目标</span>
|
||||
<small>个性化调配依据</small>
|
||||
</div>
|
||||
|
||||
<div class="custom-field-box">
|
||||
<van-field
|
||||
v-model="profile.dietPreference"
|
||||
label="饮食偏好"
|
||||
placeholder="例如:少油清淡、偏面食、少糖"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="custom-field-box">
|
||||
<van-field
|
||||
v-model="profile.healthGoal"
|
||||
label="近期诉求"
|
||||
type="textarea"
|
||||
rows="2"
|
||||
placeholder="例如:希望改善晚上入睡困难、减缓下午疲乏"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 底部固定操作按钮 -->
|
||||
<div class="sticky-action">
|
||||
<van-button
|
||||
block
|
||||
round
|
||||
color="#0c4d3f"
|
||||
:loading="loading"
|
||||
@click="submit"
|
||||
>
|
||||
✨ 立即生成我的专属健康方案
|
||||
</van-button>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
return {
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: env.VITE_API_BASE_URL || 'http://localhost:5005',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user