Initial commit: HealthCarePregnant project documentation and platform
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user