Initial commit: HealthCarePregnant project documentation and platform

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
selfrelease
2026-06-18 09:48:05 +08:00
commit eab91174db
301 changed files with 42491 additions and 0 deletions
@@ -0,0 +1,126 @@
.chat {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
}
.chat__header {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4);
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
font-weight: 700;
font-size: var(--font-lg);
}
.chat__back {
font-size: 22px;
width: 32px;
color: var(--color-text);
}
.chat__list {
flex: 1;
overflow-y: auto;
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.chat__row {
display: flex;
}
.chat__row--user {
justify-content: flex-end;
}
.chat__row--assistant {
justify-content: flex-start;
}
.chat__bubble {
max-width: 82%;
padding: 12px 14px;
border-radius: var(--radius-lg);
font-size: var(--font-md);
line-height: 1.6;
box-shadow: var(--shadow-card);
}
.chat__bubble--user {
background: var(--color-primary);
color: var(--color-text-inverse);
border-bottom-right-radius: 6px;
}
.chat__bubble--assistant {
background: var(--color-surface);
border-bottom-left-radius: 6px;
}
.chat__typing {
color: var(--color-text-soft);
}
.chat__nogrounded {
margin-top: 8px;
padding: 8px 10px;
background: var(--color-warn-soft);
color: var(--color-warn);
border-radius: var(--radius-sm);
font-size: var(--font-sm);
}
.chat__citations {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed var(--color-border);
}
.chat__citations-title {
font-size: var(--font-xs);
color: var(--color-text-soft);
margin-bottom: 6px;
}
.chat__citation {
display: flex;
align-items: flex-start;
gap: var(--space-2);
margin-bottom: 6px;
}
.chat__citation-text {
font-size: var(--font-sm);
}
.chat__input {
display: flex;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
padding-bottom: calc(var(--space-3) + env(safe-area-inset-bottom));
background: var(--color-surface);
border-top: 1px solid var(--color-border);
}
.chat__input input {
flex: 1;
padding: 12px 16px;
border-radius: var(--radius-pill);
border: 1px solid var(--color-border);
background: var(--color-bg);
}
.chat__input input:focus {
outline: none;
border-color: var(--color-primary);
}
/* SVG 图标对齐 */
.chat__back {
display: flex;
align-items: center;
justify-content: center;
}
.chat__nogrounded {
display: flex;
align-items: center;
gap: 6px;
}
.chat__nogrounded svg {
flex-shrink: 0;
}
@@ -0,0 +1,145 @@
import { useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { ArrowLeft, AlertTriangle } from 'lucide-react';
import { api, ApiError } from '../api/client';
import type { KnowledgeCitation } from '../api/types';
import './ChatPage.css';
interface Message {
id: string;
role: 'user' | 'assistant';
text: string;
grounded?: boolean;
citations?: KnowledgeCitation[];
}
const AUTHORITY_LABEL: Record<string, string> = {
authoritative: '权威',
reference: '参考',
self: '自建',
};
const WELCOME: Message = {
id: 'welcome',
role: 'assistant',
text: '你好呀~我是你的孕期助手。关于孕期饮食、监测、不适或注意事项,都可以问我。我会附上知识来源,遇到拿不准的会建议你及时就医。',
};
export function ChatPage(): JSX.Element {
const navigate = useNavigate();
const location = useLocation();
const initialQuestion = (location.state as { initialQuestion?: string } | null)?.initialQuestion;
const [messages, setMessages] = useState<Message[]>([WELCOME]);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const askedInitial = useRef(false);
async function send(question: string): Promise<void> {
const q = question.trim();
if (!q || sending) return;
const userMsg: Message = { id: `u-${Date.now()}`, role: 'user', text: q };
setMessages((prev) => [...prev, userMsg]);
setInput('');
setSending(true);
try {
const answer = await api.ask(q);
setMessages((prev) => [
...prev,
{
id: `a-${Date.now()}`,
role: 'assistant',
text: answer.answer,
grounded: answer.grounded,
citations: answer.citations,
},
]);
} catch (err) {
setMessages((prev) => [
...prev,
{
id: `e-${Date.now()}`,
role: 'assistant',
text: err instanceof ApiError ? err.message : '抱歉,我暂时没能回答,请稍后再试。',
},
]);
} finally {
setSending(false);
}
}
useEffect(() => {
if (initialQuestion && !askedInitial.current) {
askedInitial.current = true;
void send(initialQuestion);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialQuestion]);
useEffect(() => {
listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: 'smooth' });
}, [messages]);
return (
<div className="chat">
<header className="chat__header">
<button className="chat__back" onClick={() => navigate(-1)} type="button" aria-label="返回">
<ArrowLeft size={22} strokeWidth={1.75} />
</button>
<span></span>
</header>
<div className="chat__list" ref={listRef}>
{messages.map((m) => (
<div key={m.id} className={`chat__row chat__row--${m.role}`}>
<div className={`chat__bubble chat__bubble--${m.role}`}>
<p>{m.text}</p>
{m.role === 'assistant' && m.grounded === false && (
<p className="chat__nogrounded">
<AlertTriangle size={15} strokeWidth={1.9} />
</p>
)}
{m.citations && m.citations.length > 0 && (
<div className="chat__citations">
<p className="chat__citations-title"></p>
{m.citations.map((c) => (
<div key={c.id} className="chat__citation">
<span className="badge badge-ok">{AUTHORITY_LABEL[c.authority] ?? c.authority}</span>
<span className="chat__citation-text">
{c.title}
<span className="muted"> · {c.source}</span>
</span>
</div>
))}
</div>
)}
</div>
</div>
))}
{sending && (
<div className="chat__row chat__row--assistant">
<div className="chat__bubble chat__bubble--assistant chat__typing"></div>
</div>
)}
</div>
<form
className="chat__input"
onSubmit={(e) => {
e.preventDefault();
void send(input);
}}
>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="问问孕期助手…"
/>
<button className="btn btn-primary" type="submit" disabled={sending || !input.trim()}>
</button>
</form>
</div>
);
}
@@ -0,0 +1,55 @@
.data-alert {
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: var(--space-4);
margin-bottom: var(--space-3);
border-left: 4px solid var(--color-border);
box-shadow: var(--shadow-card);
}
.data-alert--medium {
border-left-color: var(--color-warn);
}
.data-alert--high {
border-left-color: var(--color-danger);
}
.data-alert__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-2);
font-size: var(--font-xs);
}
.data-alert__indicator {
font-weight: 700;
margin-bottom: 4px;
}
.data-alert__msg {
font-size: var(--font-sm);
}
.data-alert__trace {
font-size: var(--font-xs);
margin-top: 6px;
}
.data-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-border);
}
.data-row:last-child {
border-bottom: none;
}
.data-row__name {
font-weight: 600;
}
.data-row__time {
font-size: var(--font-xs);
}
.data-row__value {
display: flex;
align-items: center;
gap: var(--space-2);
font-weight: 700;
}
@@ -0,0 +1,166 @@
import { useCallback, useEffect, useState } from 'react';
import { api, ApiError } from '../api/client';
import { useAuth } from '../auth/AuthContext';
import { useToast } from '../components/Toast';
import { usePatient } from '../lib/usePatient';
import { useAutoRefresh } from '../lib/useAutoRefresh';
import { BuildArchive } from '../components/BuildArchive';
import { INDICATOR_OPTIONS } from '../api/types';
import type { Alert, Observation } from '../api/types';
import { formatTime, indicatorLabel, riskBadgeClass, riskLabel } from '../lib/format';
import './DataPage.css';
export function DataPage(): JSX.Element {
const { patientId } = useAuth();
const { patient, loading: patientLoading, reload: reloadPatient } = usePatient();
const { show } = useToast();
const [indicator, setIndicator] = useState(INDICATOR_OPTIONS[0].type);
const [value, setValue] = useState('');
const [submitting, setSubmitting] = useState(false);
const [observations, setObservations] = useState<Observation[]>([]);
const [alerts, setAlerts] = useState<Alert[]>([]);
const unit = INDICATOR_OPTIONS.find((o) => o.type === indicator)?.unit ?? '';
const load = useCallback(() => {
if (!patientId) return;
void Promise.all([api.listObservations(patientId), api.listAlerts(patientId)]).then(
([obs, al]) => {
setObservations([...obs].reverse());
setAlerts([...al].reverse());
},
);
}, [patientId]);
useEffect(() => {
load();
}, [load]);
// 多端一致:跨端(医护录入/预警)变更近实时收敛
useAutoRefresh(load);
async function record(e: React.FormEvent): Promise<void> {
e.preventDefault();
if (!patientId || value === '') {
show('请输入数值');
return;
}
setSubmitting(true);
try {
const result = await api.recordObservation(patientId, {
indicator,
value: Number(value),
source: 'manual',
});
setValue('');
if (result.observation.qcStatus === 'rejected') {
show('数值看起来不太合理,已标记但不参与分析');
} else if (result.alert) {
show(`已记录,发现${riskLabel(result.alert.level)}信号,请留意`);
} else {
show('记录成功,数值正常,真棒~');
}
load();
} catch (err) {
show(err instanceof ApiError ? err.message : '记录失败,请重试');
} finally {
setSubmitting(false);
}
}
if (!patient && !patientLoading) {
return (
<div className="page">
<h1 className="section-title" style={{ marginTop: 0 }}>
</h1>
<BuildArchive onDone={reloadPatient} />
</div>
);
}
return (
<div className="page">
<h1 className="section-title" style={{ marginTop: 0 }}>
</h1>
<form className="card" onSubmit={record}>
<div className="field">
<label></label>
<select value={indicator} onChange={(e) => setIndicator(e.target.value)}>
{INDICATOR_OPTIONS.map((o) => (
<option key={o.type} value={o.type}>
{o.label}{o.unit}
</option>
))}
</select>
</div>
<div className="field">
<label>{unit}</label>
<input
type="number"
inputMode="decimal"
step="0.1"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={`请输入${indicatorLabel(indicator)}`}
/>
</div>
<button className="btn btn-primary btn-block" type="submit" disabled={submitting}>
{submitting ? '记录中…' : '记录'}
</button>
</form>
{alerts.length > 0 && (
<>
<h2 className="section-title"></h2>
{alerts.map((a) => (
<div key={a.id} className={`data-alert data-alert--${a.level}`}>
<div className="data-alert__head">
<span className={`badge ${riskBadgeClass(a.level)}`}>{riskLabel(a.level)}</span>
<span className="muted">{formatTime(a.createdAt)}</span>
</div>
<p className="data-alert__indicator">
{indicatorLabel(a.indicator)}{a.value}
</p>
{a.messages.map((m, i) => (
<p key={i} className="data-alert__msg">
{m}
</p>
))}
<p className="data-alert__trace muted">
{a.ruleIds.join('、') || '—'}
</p>
</div>
))}
</>
)}
<h2 className="section-title"></h2>
{observations.length === 0 ? (
<p className="muted"></p>
) : (
<div className="card">
{observations.slice(0, 20).map((o) => (
<div key={o.id} className="data-row">
<div>
<p className="data-row__name">{indicatorLabel(o.indicator)}</p>
<p className="muted data-row__time">
{formatTime(o.measuredAt)} · {o.gestationalWeeks}
</p>
</div>
<div className="data-row__value">
<span>
{o.value} {o.unit}
</span>
{o.qcStatus === 'rejected' && <span className="badge badge-warn"></span>}
</div>
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,293 @@
.home__greet {
margin-top: var(--space-2);
margin-bottom: var(--space-4);
}
.home__hello {
font-size: var(--font-xl);
font-weight: 600;
}
.home__week {
font-size: var(--font-md);
color: var(--color-text-soft);
margin-top: 2px;
display: flex;
align-items: center;
}
/* ===== 渐变 Hero ===== */
.home__hero {
position: relative;
width: 100%;
display: flex;
align-items: center;
gap: var(--space-3);
padding: 20px 22px;
border-radius: var(--radius-xl);
background: var(--gradient-hero);
box-shadow: var(--shadow-hero);
color: #fff;
text-align: left;
overflow: hidden;
margin-bottom: var(--space-5);
}
.home__hero-glow {
position: absolute;
inset: 0;
background:
radial-gradient(120px 120px at 88% -10%, rgba(255, 255, 255, 0.45), transparent 70%),
radial-gradient(140px 140px at 10% 120%, rgba(255, 255, 255, 0.22), transparent 70%);
pointer-events: none;
}
.home__hero-body {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
}
.home__hero-eyebrow {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: var(--font-xs);
font-weight: 600;
opacity: 0.92;
}
.home__hero-title {
font-size: var(--font-xl);
font-weight: 700;
letter-spacing: 0.5px;
}
.home__hero-sub {
font-size: var(--font-sm);
opacity: 0.9;
}
.home__hero-cta {
position: relative;
flex-shrink: 0;
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
}
/* ===== 柔彩色调(暖系,复用于多处) ===== */
[data-tone='peach'] {
--tone-bg: var(--c-peach);
--tone-ink: var(--c-peach-ink);
}
[data-tone='rose'] {
--tone-bg: var(--c-rose);
--tone-ink: var(--c-rose-ink);
}
[data-tone='mint'] {
--tone-bg: var(--c-mint);
--tone-ink: var(--c-mint-ink);
}
[data-tone='lilac'] {
--tone-bg: var(--c-lilac);
--tone-ink: var(--c-lilac-ink);
}
/* ===== 今日健康 Quick ===== */
.home__quick {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-3);
}
.home__quick-card {
background: var(--tone-bg, var(--color-surface));
border-radius: var(--radius-lg);
padding: var(--space-4) var(--space-2);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
font-size: var(--font-sm);
font-weight: 600;
cursor: pointer;
}
.home__quick-chip {
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.7);
display: flex;
align-items: center;
justify-content: center;
color: var(--tone-ink, var(--color-primary-strong));
}
/* ===== 意图卡片 2x2 柔彩 ===== */
.home__intents {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-3);
}
.home__intent {
background: var(--tone-bg, var(--color-surface));
border-radius: var(--radius-lg);
padding: var(--space-4);
display: flex;
align-items: center;
gap: var(--space-3);
font-size: var(--font-md);
font-weight: 600;
text-align: left;
cursor: pointer;
}
.home__intent-chip {
flex-shrink: 0;
width: 38px;
height: 38px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.75);
display: flex;
align-items: center;
justify-content: center;
color: var(--tone-ink, var(--color-primary-strong));
}
.home__intent-label {
line-height: 1.3;
}
/* ===== 更多功能 ===== */
.home__more {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-2);
}
.home__more button {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
font-size: var(--font-xs);
color: var(--color-text-soft);
padding: var(--space-2) 0;
cursor: pointer;
}
.home__more-chip {
width: 46px;
height: 46px;
border-radius: 14px;
background: var(--color-surface);
box-shadow: var(--shadow-card);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-primary-strong);
}
/* ===== 情绪自评打卡 (T-D.9) ===== */
.home__emotion-card {
background: linear-gradient(135deg, rgba(255, 123, 137, 0.08) 0%, rgba(255, 171, 142, 0.08) 100%);
border: 1px solid rgba(255, 123, 137, 0.15);
border-radius: var(--radius-xl);
padding: var(--space-4) var(--space-5);
margin-bottom: var(--space-5);
}
.emotion-card__title {
font-size: var(--font-md);
font-weight: 700;
color: var(--color-text-strong);
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.emotion-card__box {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.emotion-card__desc {
font-size: var(--font-sm);
color: var(--color-text-soft);
margin: 0;
}
.emotion-card__selectors {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 4px;
}
.emotion-score-btn {
height: 32px;
border: 1px solid rgba(255, 123, 137, 0.15);
background: #fff;
color: var(--color-text-soft);
font-weight: 600;
font-size: var(--font-sm);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
}
.emotion-score-btn:hover {
background: rgba(255, 123, 137, 0.05);
border-color: rgba(255, 123, 137, 0.3);
}
.emotion-score-btn.active {
background: linear-gradient(135deg, #ff7b89 0%, #ffab8e 100%);
color: #fff;
border-color: transparent;
box-shadow: 0 2px 8px rgba(255, 123, 137, 0.25);
}
.emotion-card__textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid rgba(255, 123, 137, 0.15);
border-radius: var(--radius-md);
background: #fff;
font-size: var(--font-sm);
line-height: 1.5;
color: var(--color-text);
resize: none;
font-family: inherit;
transition: all 0.2s;
}
.emotion-card__textarea:focus {
border-color: rgba(255, 123, 137, 0.4);
outline: none;
box-shadow: 0 0 0 2px rgba(255, 123, 137, 0.05);
}
.emotion-card__submit-btn {
width: 100%;
height: 40px;
background: linear-gradient(135deg, #ff7b89 0%, #ffab8e 100%);
color: #fff;
font-weight: 600;
font-size: var(--font-md);
border-radius: var(--radius-md);
border: none;
cursor: pointer;
box-shadow: 0 2px 10px rgba(255, 123, 137, 0.2);
transition: opacity 0.2s;
}
.emotion-card__submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.emotion-card__result {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: var(--font-sm);
line-height: 1.5;
color: var(--color-text-soft);
background: #fff;
border: 1px solid rgba(59, 201, 219, 0.15);
border-radius: var(--radius-md);
padding: 12px 16px;
}
.emotion-card__result strong {
color: var(--color-text-strong);
font-weight: 700;
}
@@ -0,0 +1,242 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Apple,
ArrowRight,
BarChart3,
CalendarCheck,
ClipboardCheck,
Droplet,
FileText,
HeartHandshake,
Leaf,
LineChart,
PencilLine,
Sparkles,
Sprout,
Heart,
Smile,
CheckCircle2,
type LucideIcon,
} from 'lucide-react';
import { api } from '../api/client';
import { useToast } from '../components/Toast';
import { usePatient } from '../lib/usePatient';
import { BuildArchive } from '../components/BuildArchive';
import { greeting, riskBadgeClass, riskLabel } from '../lib/format';
import './HomePage.css';
type Tone = 'peach' | 'rose' | 'mint' | 'lilac';
interface Intent {
label: string;
q?: string;
to?: string;
icon: LucideIcon;
tone: Tone;
}
const INTENTS: Intent[] = [
{ label: '我能吃这个吗?', q: '孕期可以吃西瓜吗', icon: Apple, tone: 'peach' },
{ label: '记录今天的血糖', to: '/data', icon: Droplet, tone: 'rose' },
{ label: '今天该注意什么?', q: '孕期日常需要注意什么', icon: Leaf, tone: 'mint' },
{ label: '孕期不适怎么办', q: '孕期恶心想吐怎么办', icon: HeartHandshake, tone: 'lilac' },
];
const QUICK: { label: string; to: string; icon: LucideIcon; tone: Tone }[] = [
{ label: '今日打卡', to: '/data', icon: ClipboardCheck, tone: 'peach' },
{ label: '血糖趋势', to: '/data', icon: LineChart, tone: 'rose' },
{ label: '产检提醒', to: '/tasks', icon: CalendarCheck, tone: 'mint' },
];
export function HomePage(): JSX.Element {
const navigate = useNavigate();
const { patient, loading, reload } = usePatient();
const { show } = useToast();
const [emotionScore, setEmotionScore] = useState<number | null>(null);
const [emotionNote, setEmotionNote] = useState('');
const [hasSubmittedEmotion, setHasSubmittedEmotion] = useState(false);
const [submittingEmotion, setSubmittingEmotion] = useState(false);
async function submitEmotion(): Promise<void> {
if (!emotionScore) {
show('请选择一个情绪分值哦~');
return;
}
setSubmittingEmotion(true);
try {
await api.createEmotion({ score: emotionScore, note: emotionNote || '今日情绪状况良好' });
show('打卡成功!AI 已过滤异常信号并同步至管理师,祝您好心情🌸');
setHasSubmittedEmotion(true);
setEmotionNote('');
} catch (err) {
show('心情记录失败,请稍后重试');
} finally {
setSubmittingEmotion(false);
}
}
function goChat(q?: string): void {
navigate('/chat', q ? { state: { initialQuestion: q } } : undefined);
}
if (!patient && !loading) {
return (
<div className="page">
<h1 className="section-title" style={{ marginTop: 0 }}>
</h1>
<BuildArchive onDone={reload} />
</div>
);
}
return (
<div className="page home">
<header className="home__greet">
<p className="home__hello">
{greeting()}{patient?.name ?? ''}
</p>
{patient && (
<p className="home__week">
{patient.gestationalWeeks}
{patient.gestationalDays ? ` ${patient.gestationalDays}` : ''}
<span className={`badge ${riskBadgeClass(patient.initialRiskLevel)}`} style={{ marginLeft: 8 }}>
{riskLabel(patient.initialRiskLevel)}
</span>
</p>
)}
</header>
{/* 渐变 Hero 聊天主入口(AI-first */}
<button className="home__hero" onClick={() => goChat()} type="button">
<div className="home__hero-glow" aria-hidden />
<div className="home__hero-body">
<span className="home__hero-eyebrow">
<Sparkles size={14} strokeWidth={2} /> AI
</span>
<span className="home__hero-title"></span>
<span className="home__hero-sub"></span>
</div>
<span className="home__hero-cta" aria-hidden>
<ArrowRight size={22} strokeWidth={2.2} />
</span>
</button>
{/* 暖色暖系低焦虑今日心情自评打卡(T-D.9) */}
<div className="home__emotion-card">
<h3 className="emotion-card__title">
<Heart size={16} fill="var(--color-destructive)" stroke="none" />
</h3>
{!hasSubmittedEmotion ? (
<div className="emotion-card__box">
<p className="emotion-card__desc">(1-10)</p>
<div className="emotion-card__selectors">
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((score) => (
<button
key={score}
type="button"
className={`emotion-score-btn ${emotionScore === score ? 'active' : ''}`}
onClick={() => setEmotionScore(score)}
>
{score === 10 ? <Smile size={14} /> : score}
</button>
))}
</div>
<textarea
className="emotion-card__textarea"
placeholder="今天有什么烦心事,或者身体上有什么不舒服吗?我们会实时保护您的私密信息,若有剧烈焦虑,个案管理师将主动为您致电疏导..."
value={emotionNote}
onChange={(e) => setEmotionNote(e.target.value)}
rows={2}
/>
<button
type="button"
className="emotion-card__submit-btn"
onClick={submitEmotion}
disabled={submittingEmotion}
>
{submittingEmotion ? '心情记录中…' : '提交今日自评'}
</button>
</div>
) : (
<div className="emotion-card__result animate-fade-in">
<CheckCircle2 size={18} style={{ color: 'var(--color-calm)' }} />
<span><strong>{emotionScore}</strong> · AI 🌸</span>
</div>
)}
</div>
{/* 今日健康 */}
<h2 className="section-title"></h2>
<div className="home__quick">
{QUICK.map((q) => (
<button
key={q.label}
className="home__quick-card"
data-tone={q.tone}
onClick={() => navigate(q.to)}
type="button"
>
<span className="home__quick-chip">
<q.icon size={22} strokeWidth={1.9} />
</span>
{q.label}
</button>
))}
</div>
{/* 意图卡片 */}
<h2 className="section-title"> / </h2>
<div className="home__intents">
{INTENTS.map((it) => (
<button
key={it.label}
className="home__intent"
data-tone={it.tone}
onClick={() => (it.to ? navigate(it.to) : goChat(it.q))}
type="button"
>
<span className="home__intent-chip">
<it.icon size={20} strokeWidth={1.9} />
</span>
<span className="home__intent-label">{it.label}</span>
</button>
))}
</div>
{/* 更多功能 */}
<h2 className="section-title"></h2>
<div className="home__more">
<button onClick={() => navigate('/data')} type="button">
<span className="home__more-chip">
<PencilLine size={22} strokeWidth={1.8} />
</span>
</button>
<button onClick={() => navigate('/me')} type="button">
<span className="home__more-chip">
<FileText size={22} strokeWidth={1.8} />
</span>
</button>
<button onClick={() => navigate('/data')} type="button">
<span className="home__more-chip">
<BarChart3 size={22} strokeWidth={1.8} />
</span>
</button>
<button onClick={() => goChat('孕期可以做哪些调养')} type="button">
<span className="home__more-chip">
<Sprout size={22} strokeWidth={1.8} />
</span>
</button>
</div>
</div>
);
}
@@ -0,0 +1,105 @@
.login {
display: flex;
flex-direction: column;
padding-top: var(--space-6);
}
.login__hero {
text-align: center;
margin-bottom: var(--space-5);
}
.login__logo {
font-size: 56px;
line-height: 1;
}
.login__hero h1 {
font-size: var(--font-xxl);
margin-top: var(--space-2);
}
.login__tabs {
display: flex;
background: var(--color-surface-soft);
border-radius: var(--radius-pill);
padding: 4px;
margin-bottom: var(--space-4);
}
.login__tabs button {
flex: 1;
padding: 10px;
border-radius: var(--radius-pill);
font-weight: 600;
color: var(--color-text-soft);
}
.login__tabs button.is-active {
background: var(--color-surface);
color: var(--color-primary-strong);
box-shadow: var(--shadow-card);
}
.login__consent {
display: flex;
align-items: flex-start;
gap: var(--space-2);
font-size: var(--font-sm);
color: var(--color-text-soft);
margin-bottom: var(--space-4);
}
.login__consent input {
margin-top: 4px;
}
.login__consent a {
color: var(--color-primary-strong);
text-decoration: underline;
}
.login__hint {
margin-top: var(--space-4);
font-size: var(--font-xs);
}
.login__demo-panel {
margin-top: var(--space-4);
padding: var(--space-3) var(--space-4);
border: 1px dashed var(--color-primary-light, #ffd3e0);
background-color: var(--color-surface-soft, #fff5f7);
}
.login__demo-title {
font-size: var(--font-sm);
font-weight: 600;
color: var(--color-primary-strong, #c2185b);
margin-bottom: var(--space-2);
}
.login__demo-buttons {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.login__demo-buttons .btn {
flex: 1;
padding: 6px 12px;
font-size: var(--font-xs);
border-radius: var(--radius-md, 8px);
}
.login__demo-desc {
font-size: 11px;
line-height: 1.4;
color: var(--color-text-muted, #757575);
}
/* SVG 图标 */
.login__logo {
color: var(--color-primary-strong);
display: flex;
justify-content: center;
}
.login__demo-title {
display: flex;
align-items: center;
gap: 6px;
}
@@ -0,0 +1,170 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Flower2, Lightbulb } from 'lucide-react';
import { useAuth } from '../auth/AuthContext';
import { useToast } from '../components/Toast';
import { ApiError } from '../api/client';
import type { Role } from '../api/types';
import './LoginPage.css';
type Mode = 'login' | 'register';
export function LoginPage(): JSX.Element {
const { login, register } = useAuth();
const { show } = useToast();
const navigate = useNavigate();
const [mode, setMode] = useState<Mode>('login');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [role, setRole] = useState<Role>('patient');
const [consent, setConsent] = useState(false);
const [submitting, setSubmitting] = useState(false);
const needConsent = role === 'patient' || role === 'family';
async function handleSubmit(e: React.FormEvent): Promise<void> {
e.preventDefault();
if (!username.trim() || !password) {
show('请填写用户名和密码');
return;
}
if (mode === 'register' && needConsent && !consent) {
show('请先阅读并签署知情同意');
return;
}
setSubmitting(true);
try {
if (mode === 'login') {
await login(username.trim(), password);
} else {
await register({ username: username.trim(), password, role, consent });
}
navigate('/home', { replace: true });
} catch (err) {
show(err instanceof ApiError ? err.message : '操作失败,请重试');
} finally {
setSubmitting(false);
}
}
function fillDemoUser(usernameVal: string, passwordVal: string, roleVal: Role) {
setUsername(usernameVal);
setPassword(passwordVal);
setRole(roleVal);
setConsent(true);
}
return (
<div className="app-shell">
<div className="page login">
<div className="login__hero">
<div className="login__logo" aria-hidden>
<Flower2 size={48} strokeWidth={1.5} />
</div>
<h1></h1>
<p className="muted"></p>
</div>
<div className="login__tabs">
<button
className={mode === 'login' ? 'is-active' : ''}
onClick={() => setMode('login')}
type="button"
>
</button>
<button
className={mode === 'register' ? 'is-active' : ''}
onClick={() => setMode('register')}
type="button"
>
</button>
</div>
<form className="card" onSubmit={handleSubmit}>
<div className="field">
<label></label>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="请输入用户名"
autoComplete="username"
/>
</div>
<div className="field">
<label></label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
/>
</div>
{mode === 'register' && (
<>
<div className="field">
<label></label>
<select value={role} onChange={(e) => setRole(e.target.value as Role)}>
<option value="patient"></option>
<option value="family"></option>
</select>
</div>
{needConsent && (
<label className="login__consent">
<input
type="checkbox"
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
/>
<span>
<a href="/onboarding" onClick={(e) => e.stopPropagation()}>
</a>
</span>
</label>
)}
</>
)}
<button className="btn btn-primary btn-block" type="submit" disabled={submitting}>
{submitting ? '请稍候…' : mode === 'login' ? '登录' : '注册并开始'}
</button>
</form>
<div className="login__demo-panel card">
<p className="login__demo-title">
<Lightbulb size={16} strokeWidth={1.75} />
</p>
<div className="login__demo-buttons">
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => fillDemoUser('test_pregnant_01', '12345678', 'patient')}
>
01
</button>
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => fillDemoUser('test_pregnant_02', '12345678', 'patient')}
>
02
</button>
</div>
<p className="login__demo-desc muted">
*
</p>
</div>
<p className="muted center login__hint">
使
</p>
</div>
</div>
);
}
@@ -0,0 +1,108 @@
/* ===== 渐变 Profile Hero ===== */
.me__hero {
position: relative;
display: flex;
align-items: center;
gap: var(--space-4);
padding: var(--space-5);
margin: var(--space-2) 0 var(--space-5);
border-radius: var(--radius-xl);
background: var(--gradient-hero);
box-shadow: var(--shadow-hero);
color: #fff;
overflow: hidden;
}
.me__hero-glow {
position: absolute;
inset: 0;
background:
radial-gradient(130px 130px at 90% -20%, rgba(255, 255, 255, 0.4), transparent 70%),
radial-gradient(150px 150px at 5% 130%, rgba(255, 255, 255, 0.2), transparent 70%);
pointer-events: none;
}
.me__hero-info {
position: relative;
}
.me__hero-meta {
font-size: var(--font-sm);
opacity: 0.92;
margin-top: 2px;
}
.me__avatar {
position: relative;
width: 64px;
height: 64px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.28);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
flex-shrink: 0;
}
.me__name {
font-size: var(--font-xl);
font-weight: 700;
}
.me__archive .me__row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid var(--color-border);
}
.me__archive .me__row:last-of-type {
border-bottom: none;
}
.me__factors {
margin-top: var(--space-3);
}
.me__factor {
margin: 0 6px 6px 0;
}
.me__list {
padding: 0;
}
.me__list-item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4);
border-bottom: 1px solid var(--color-border);
font-size: var(--font-md);
}
.me__list-item:last-child {
border-bottom: none;
}
.me__list-right {
display: inline-flex;
align-items: center;
gap: 4px;
}
/* 关怀码 */
.me__copy {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: var(--radius-pill);
background: var(--color-primary-soft);
color: var(--color-primary-strong);
font-size: var(--font-sm);
font-weight: 600;
cursor: pointer;
}
.me__code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.3px;
}
.me__codehint {
font-size: var(--font-xs);
margin: 4px 0 var(--space-2);
}
@@ -0,0 +1,133 @@
import { useNavigate } from 'react-router-dom';
import { ChevronRight, Copy, UserRound } from 'lucide-react';
import { useAuth } from '../auth/AuthContext';
import { usePatient } from '../lib/usePatient';
import { useToast } from '../components/Toast';
import { BuildArchive } from '../components/BuildArchive';
import { riskBadgeClass, riskLabel } from '../lib/format';
import './MePage.css';
const ROLE_LABEL: Record<string, string> = {
patient: '孕妇',
family: '家属',
case_manager: '个案管理师',
physician: '医生',
operator: '运营',
admin: '管理员',
};
export function MePage(): JSX.Element {
const { user, logout } = useAuth();
const { patient, loading, reload } = usePatient();
const { show } = useToast();
const navigate = useNavigate();
async function copyCareCode(): Promise<void> {
if (!patient) return;
try {
await navigator.clipboard.writeText(patient.patientNo);
show('关怀码已复制,发给家属即可绑定');
} catch {
show(`复制失败,请手动复制:${patient.patientNo}`);
}
}
return (
<div className="page">
<header className="me__hero">
<div className="me__hero-glow" aria-hidden />
<div className="me__avatar" aria-hidden>
<UserRound size={34} strokeWidth={1.5} />
</div>
<div className="me__hero-info">
<p className="me__name">{patient?.name ?? user?.username}</p>
<p className="me__hero-meta">
{ROLE_LABEL[user?.role ?? ''] ?? user?.role}
{patient ? ` · 孕 ${patient.gestationalWeeks}` : ''}
</p>
</div>
</header>
{!patient && !loading ? (
<BuildArchive onDone={reload} />
) : (
patient && (
<div className="card me__archive">
<h2 className="section-title" style={{ marginTop: 0 }}>
</h2>
<div className="me__row">
<span className="muted">怀</span>
<button className="me__copy" onClick={copyCareCode} type="button">
<span className="me__code">{patient.patientNo}</span>
<Copy size={14} strokeWidth={1.9} />
</button>
</div>
<p className="me__codehint muted">怀TA </p>
<div className="me__row">
<span className="muted"></span>
<span>
{patient.gestationalWeeks} {patient.gestationalDays}
</span>
</div>
<div className="me__row">
<span className="muted"></span>
<span>{patient.edd?.slice(0, 10)}</span>
</div>
<div className="me__row">
<span className="muted"></span>
<span className={`badge ${riskBadgeClass(patient.initialRiskLevel)}`}>
{riskLabel(patient.initialRiskLevel)}
</span>
</div>
{patient.prePregnancyBmi != null && (
<div className="me__row">
<span className="muted"> BMI</span>
<span>{patient.prePregnancyBmi.toFixed(1)}</span>
</div>
)}
{patient.initialRiskFactors.length > 0 && (
<div className="me__factors">
<p className="muted" style={{ marginBottom: 6 }}>
</p>
{patient.initialRiskFactors.map((f, i) => (
<span key={i} className="badge badge-warn me__factor">
{f}
</span>
))}
</div>
)}
</div>
)
)}
<h2 className="section-title"></h2>
<div className="card me__list">
<button className="me__list-item" onClick={() => navigate('/onboarding')} type="button">
<span></span>
<span className="me__list-right muted">
{user?.consentSigned ? '已签署' : '未签署'}
<ChevronRight size={16} strokeWidth={1.75} />
</span>
</button>
<button className="me__list-item" onClick={() => navigate('/chat')} type="button">
<span></span>
<span className="me__list-right muted">
<ChevronRight size={16} strokeWidth={1.75} />
</span>
</button>
</div>
<button
className="btn btn-ghost btn-block"
style={{ marginTop: 24 }}
onClick={logout}
type="button"
>
退
</button>
</div>
);
}
@@ -0,0 +1,39 @@
import { useNavigate } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
export function OnboardingPage(): JSX.Element {
const navigate = useNavigate();
return (
<div className="app-shell">
<div className="page">
<button className="btn btn-ghost" onClick={() => navigate(-1)} type="button">
<ArrowLeft size={18} strokeWidth={1.75} />
</button>
<h1 className="section-title"></h1>
<div className="card" style={{ lineHeight: 1.8 }}>
<p>
使
</p>
<p style={{ marginTop: 12 }}></p>
<ul style={{ paddingLeft: 20, marginTop: 8 }}>
<li>使</li>
<li>访</li>
<li></li>
<li></li>
</ul>
<p style={{ marginTop: 12 }} className="muted">
-
</p>
</div>
<button
className="btn btn-primary btn-block"
style={{ marginTop: 16 }}
onClick={() => navigate(-1)}
type="button"
>
</button>
</div>
</div>
);
}
@@ -0,0 +1,81 @@
.tasks__quick {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-2);
}
.tasks__quick button {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: var(--space-3) 0;
border-radius: var(--radius-md);
background: var(--color-surface-soft);
font-size: var(--font-sm);
font-weight: 600;
}
.tasks__quick button span {
font-size: 24px;
}
.task-card {
display: flex;
align-items: center;
gap: var(--space-3);
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: var(--space-4);
margin-bottom: var(--space-3);
box-shadow: var(--shadow-card);
}
.task-card.is-done {
opacity: 0.6;
}
.task-card__icon {
font-size: 28px;
}
.task-card__body {
flex: 1;
}
.task-card__title {
font-weight: 700;
display: flex;
align-items: center;
gap: var(--space-2);
}
.task-card__msg {
font-size: var(--font-sm);
margin-top: 2px;
}
.task-card__time {
font-size: var(--font-xs);
margin-top: 4px;
}
.task-card__check {
width: 32px;
height: 32px;
border-radius: 50%;
border: 2px solid var(--color-border);
color: var(--color-text-inverse);
font-weight: 700;
flex-shrink: 0;
}
.task-card__check.is-done {
background: var(--color-ok);
border-color: var(--color-ok);
}
/* SVG 图标:品牌色 + 对齐 */
.tasks__quick button svg {
color: var(--color-primary-strong);
}
.task-card__icon {
display: flex;
align-items: center;
color: var(--color-primary-strong);
}
.task-card__check {
display: flex;
align-items: center;
justify-content: center;
}
@@ -0,0 +1,149 @@
import { useCallback, useEffect, useState } from 'react';
import {
Activity,
Armchair,
Bell,
CalendarCheck,
Check,
Droplet,
Footprints,
Pill,
type LucideIcon,
} from 'lucide-react';
import { api, ApiError } from '../api/client';
import { useAuth } from '../auth/AuthContext';
import { useToast } from '../components/Toast';
import { usePatient } from '../lib/usePatient';
import { useAutoRefresh } from '../lib/useAutoRefresh';
import { BuildArchive } from '../components/BuildArchive';
import type { Reminder, ReminderType } from '../api/types';
import { formatTime, reminderLabel } from '../lib/format';
import './TasksPage.css';
const QUICK_TYPES: { type: ReminderType; label: string; icon: LucideIcon }[] = [
{ type: 'measurement', label: '监测打卡', icon: Activity },
{ type: 'water', label: '喝水', icon: Droplet },
{ type: 'exercise', label: '运动', icon: Footprints },
{ type: 'checkup', label: '产检', icon: CalendarCheck },
{ type: 'medication', label: '服药', icon: Pill },
];
const TYPE_ICON: Record<string, LucideIcon> = {
measurement: Activity,
water: Droplet,
exercise: Footprints,
rest: Armchair,
checkup: CalendarCheck,
medication: Pill,
};
export function TasksPage(): JSX.Element {
const { patientId } = useAuth();
const { patient, loading: patientLoading, reload: reloadPatient } = usePatient();
const { show } = useToast();
const [reminders, setReminders] = useState<Reminder[]>([]);
const [done, setDone] = useState<Set<string>>(new Set());
const load = useCallback(() => {
if (!patientId) return;
void api.listReminders(patientId).then((list) => setReminders([...list].reverse()));
}, [patientId]);
useEffect(() => {
load();
}, [load]);
// 多端一致:管理师下发的提醒近实时出现
useAutoRefresh(load);
async function addReminder(type: ReminderType): Promise<void> {
if (!patientId) return;
try {
const r = await api.dispatchReminder(patientId, { type });
if (r.adjustedForRisk) {
show('考虑到你的健康状况,已把运动调整为休息提醒');
} else {
show('已添加提醒');
}
load();
} catch (err) {
show(err instanceof ApiError ? err.message : '添加失败,请重试');
}
}
function toggleDone(id: string): void {
setDone((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
if (!patient && !patientLoading) {
return (
<div className="page">
<h1 className="section-title" style={{ marginTop: 0 }}>
</h1>
<BuildArchive onDone={reloadPatient} />
</div>
);
}
return (
<div className="page">
<h1 className="section-title" style={{ marginTop: 0 }}>
/
</h1>
<div className="card">
<p className="muted" style={{ marginBottom: 12 }}>
</p>
<div className="tasks__quick">
{QUICK_TYPES.map((q) => (
<button key={q.type} onClick={() => addReminder(q.type)} type="button">
<q.icon size={24} strokeWidth={1.75} />
{q.label}
</button>
))}
</div>
</div>
<h2 className="section-title"></h2>
{reminders.length === 0 ? (
<p className="muted"></p>
) : (
reminders.map((r) => {
const isDone = done.has(r.id);
const Icon = TYPE_ICON[r.effectiveType] ?? Bell;
return (
<div key={r.id} className={`task-card${isDone ? ' is-done' : ''}`}>
<span className="task-card__icon">
<Icon size={26} strokeWidth={1.75} />
</span>
<div className="task-card__body">
<p className="task-card__title">
{reminderLabel(r.effectiveType)}
{r.adjustedForRisk && <span className="badge badge-warn"></span>}
</p>
<p className="task-card__msg muted">{r.message}</p>
<p className="task-card__time muted">{formatTime(r.scheduledAt)}</p>
</div>
<button
className={`task-card__check${isDone ? ' is-done' : ''}`}
onClick={() => toggleDone(r.id)}
type="button"
aria-label="打卡"
>
{isDone ? <Check size={18} strokeWidth={2.5} /> : null}
</button>
</div>
);
})
)}
</div>
);
}