refactor: 统一四个预警历史页面布局结构

- blood-oxygen-alerts.html:更新 stats-card 结构,添加最近预警时间显示
- temperature-alerts.html:完全重构为标准格式,采用与 heart-rate-alerts.html 相同的结构
- stress-alerts.html:已符合标准格式
- 四个页面现采用统一的 stats-card + alert-card 布局,保持各自的颜色主题
- 新增 health-monitor 模块 CHANGELOG.md 文档

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 11:25:11 +08:00
co-authored by Claude Haiku 4.5
parent b8382c862f
commit 852fe14dab
329 changed files with 284757 additions and 21 deletions
+15 -2
View File
@@ -7,9 +7,21 @@
<value>
<TabSessionState>
<option name="provider" value="claude" />
<option name="sessionId" value="6fe84250-c2b0-4b03-9596-b72a40d7e938" />
<option name="sessionId" value="be7f2f5b-b52b-4172-af7b-8e4e5d8e77d3" />
<option name="cwd" value="$PROJECT_DIR$" />
<option name="model" value="claude-opus-4-6" />
<option name="model" value="claude-haiku-4-5" />
<option name="permissionMode" value="bypassPermissions" />
<option name="reasoningEffort" value="medium" />
</TabSessionState>
</value>
</entry>
<entry key="1">
<value>
<TabSessionState>
<option name="provider" value="claude" />
<option name="sessionId" value="f13054e9-ec6f-48ac-a891-00a7473a5a90" />
<option name="cwd" value="$PROJECT_DIR$" />
<option name="model" value="claude-haiku-4-5" />
<option name="permissionMode" value="bypassPermissions" />
<option name="reasoningEffort" value="medium" />
</TabSessionState>
@@ -17,5 +29,6 @@
</entry>
</map>
</option>
<option name="tabCount" value="2" />
</component>
</project>
@@ -0,0 +1,205 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>体检预约</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: #13c2c2; --primary-dark: #0f9b9b; --primary-light: #e6fffb;
--danger: #ff4d4f; --danger-light: #fff1f0;
--text-primary: #1a1a1a; --text-secondary: #666666; --text-muted: #8c8c8c;
--bg-page: #f0f2f5; --bg-content: #f7f9fb;
--shadow: 0 10px 30px rgba(19, 194, 194, 0.08);
}
body {
background: var(--bg-page); display: flex; justify-content: center; align-items: flex-start;
min-height: 100vh; padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
color: var(--text-primary);
}
.phone-shell {
width: 390px; min-height: 844px; background: var(--bg-content);
border-radius: 44px; box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0;
overflow: hidden; position: relative; display: flex; flex-direction: column;
}
.phone-status-bar {
height: 44px; background: #fff; display: flex; align-items: center;
justify-content: space-between; padding: 0 24px; font-size: 12px; font-weight: 600; flex-shrink: 0;
}
.phone-status-bar .time { font-size: 15px; font-weight: 700; }
.phone-status-bar .icons { display: flex; gap: 6px; align-items: center; }
.nav-bar {
height: 44px; display: flex; align-items: center; padding: 0 16px;
background: #fff; border-bottom: 1px solid #f0f0f0; flex-shrink: 0; position: relative;
}
.nav-back { font-size: 18px; color: var(--text-primary); cursor: pointer; line-height: 1; text-decoration: none; }
.nav-title { position: absolute; left: 50%; transform: translateX(-50%); font-size: 17px; font-weight: 600; }
.scroll-content {
flex: 1; overflow-y: auto;
background: linear-gradient(180deg, #effbfb 0%, #f7f9fb 180px, #f7f9fb 100%);
padding-bottom: 100px;
}
.scroll-content::-webkit-scrollbar { display: none; }
.page-body { padding: 16px; }
/* 步骤条 */
.stepper { display: flex; align-items: center; justify-content: center; margin-bottom: 18px; padding: 16px 0; }
.stepper__item { display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; text-decoration: none; }
.stepper__item--future { cursor: default; pointer-events: none; opacity: 0.45; }
.stepper__dot {
width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700; background: #e8e8e8; color: #bfbfbf; transition: all 0.25s;
}
.stepper__item--done .stepper__dot { background: var(--primary); color: #fff; }
.stepper__item--active { cursor: default; pointer-events: none; }
.stepper__item--active .stepper__dot {
width: 34px; height: 34px; font-size: 14px;
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%); color: #fff;
box-shadow: 0 0 0 6px rgba(19,194,194,0.2);
}
.stepper__label { font-size: 12px; color: var(--text-muted); white-space: nowrap; transition: all 0.2s; }
.stepper__item--done .stepper__label { color: var(--primary-dark); }
.stepper__item--active .stepper__label {
color: var(--primary-dark); font-weight: 700; font-size: 13px;
background: var(--primary-light); padding: 2px 10px; border-radius: 10px;
}
.stepper__item--done:hover .stepper__dot { transform: scale(1.12); box-shadow: 0 0 0 4px rgba(19,194,194,0.15); }
.stepper__item--done:hover .stepper__label { text-decoration: underline; text-underline-offset: 3px; }
.stepper__line { width: 44px; height: 3px; background: #e8e8e8; margin: 0 8px; margin-bottom: 22px; border-radius: 2px; }
.stepper__line--done { background: var(--primary); }
.section-title { font-size: 16px; font-weight: 700; margin-bottom: 14px; }
/* 信息卡片 */
.info-card {
background: #fff; border-radius: 20px; padding: 18px 16px; margin-bottom: 14px;
box-shadow: var(--shadow); border: 1px solid rgba(19,194,194,0.06);
}
.info-card__row {
display: flex; justify-content: space-between; align-items: center;
padding: 12px 0; border-bottom: 1px solid #f0f0f0;
}
.info-card__row:first-child { padding-top: 0; }
.info-card__row:last-child { border-bottom: none; padding-bottom: 0; }
.info-card__label { font-size: 13px; color: var(--text-muted); }
.info-card__value { font-size: 14px; font-weight: 600; color: var(--text-primary); text-align: right; }
.info-card__value--highlight { color: var(--primary-dark); }
/* 须知卡片 */
.tips-card {
background: #fff; border-radius: 20px; padding: 16px; margin-bottom: 14px;
border: 1px solid #eef5f5; box-shadow: 0 8px 24px rgba(0,0,0,0.04);
}
.tips-card__title { font-size: 15px; font-weight: 700; margin-bottom: 10px; }
.tips-card__item {
display: flex; gap: 8px; font-size: 13px; line-height: 1.7;
color: var(--text-secondary); margin-bottom: 8px;
}
.tips-card__item:last-child { margin-bottom: 0; }
.tips-card__dot {
width: 6px; height: 6px; border-radius: 50%; background: var(--primary);
margin-top: 8px; flex-shrink: 0;
}
/* 底部按钮 */
.bottom-actions {
position: absolute; left: 16px; right: 16px; bottom: 18px;
display: flex; gap: 12px; padding-bottom: 16px;
}
.bottom-actions__btn {
flex: 1; height: 48px; border-radius: 24px; font-size: 15px; font-weight: 600; border: none; cursor: pointer;
}
.bottom-actions__btn--ghost {
background: var(--danger-light); color: var(--danger); border: 1px solid rgba(255,77,79,0.2);
}
.bottom-actions__btn--primary {
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%);
color: #fff; box-shadow: 0 10px 20px rgba(19,194,194,0.22);
}
/* Toast */
.toast {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%) scale(0.9);
background: rgba(0,0,0,0.75); color: #fff; padding: 14px 22px; border-radius: 12px;
font-size: 13px; line-height: 1.6; text-align: center; z-index: 200;
opacity: 0; pointer-events: none; transition: all 0.25s ease; max-width: 280px;
}
.toast.show { opacity: 1; transform: translate(-50%, -50%) scale(1); }
</style>
</head>
<body>
<div class="phone-shell">
<div class="phone-status-bar">
<span class="time">12:00</span>
<div class="icons">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<div class="nav-bar">
<a class="nav-back" href="index.html">&#8249;</a>
<span class="nav-title">体检预约</span>
</div>
<div class="scroll-content">
<div class="page-body">
<!-- [交互] 步骤条可点击跳转 -->
<div class="stepper">
<!-- [交互] 已提交项目,点击步骤一弹提示,不跳转 -->
<div class="stepper__item stepper__item--done" onclick="showToast()"><div class="stepper__dot">&#10003;</div><div class="stepper__label">选择医院</div></div>
<div class="stepper__line stepper__line--done"></div>
<div class="stepper__item stepper__item--done" onclick="showToast()"><div class="stepper__dot">&#10003;</div><div class="stepper__label">选择项目</div></div>
<div class="stepper__line stepper__line--done"></div>
<div class="stepper__item stepper__item--active"><div class="stepper__dot">&#10003;</div><div class="stepper__label">预约日期</div></div>
</div>
<!-- 预约成功信息 -->
<div class="info-card">
<div class="section-title">预约信息</div>
<div class="info-card__row">
<span class="info-card__label">体检医院</span>
<span class="info-card__value">宁夏宝石花医院</span>
</div>
<div class="info-card__row">
<span class="info-card__label">体检员工</span>
<span class="info-card__value">刘韵琴</span>
</div>
<div class="info-card__row">
<span class="info-card__label">体检地址</span>
<span class="info-card__value">银川市西夏区怀远路148号</span>
</div>
<div class="info-card__row">
<span class="info-card__label">体检日期</span>
<span class="info-card__value info-card__value--highlight">2026年3月23日</span>
</div>
<div class="info-card__row">
<span class="info-card__label">体检项目数</span>
<span class="info-card__value info-card__value--highlight">9 项</span>
</div>
</div>
<!-- 体检须知 -->
<div class="tips-card">
<div class="tips-card__title">体检须知</div>
<div class="tips-card__item"><span class="tips-card__dot"></span><span>体检前一天请勿饮酒,避免高脂饮食。</span></div>
<div class="tips-card__item"><span class="tips-card__dot"></span><span>体检当天需空腹,请勿进食或饮水。</span></div>
<div class="tips-card__item"><span class="tips-card__dot"></span><span>请携带本人身份证件按时前往医院。</span></div>
<div class="tips-card__item"><span class="tips-card__dot"></span><span>若需取消预约,请至少提前一天操作,避免影响重新预约。</span></div>
</div>
</div>
</div>
<div class="bottom-actions">
<!-- [交互] 取消预约 -->
<button class="bottom-actions__btn bottom-actions__btn--ghost" onclick="window.location.href='booking-date.html'">取消预约</button>
<!-- [交互] 查看预约详情,跳转至 my-appointments-detail.html -->
<button class="bottom-actions__btn bottom-actions__btn--primary" onclick="window.location.href='my-appointments-detail.html?done=0&total=9'">查看预约详情</button>
</div>
<div class="toast" id="stepToast">当前已预约体检,无法重新选择,请先取消预约</div>
</div>
<script>
let toastTimer = null;
function showToast() {
const toast = document.getElementById('stepToast');
toast.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toast.classList.remove('show'); }, 2200);
}
</script>
</body>
</html>
@@ -0,0 +1,417 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>体检预约</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: #13c2c2; --primary-dark: #0f9b9b; --primary-light: #e6fffb;
--text-primary: #1a1a1a; --text-secondary: #666666; --text-muted: #8c8c8c;
--bg-page: #f0f2f5; --bg-content: #f7f9fb;
--shadow: 0 10px 30px rgba(19, 194, 194, 0.08);
}
body {
background: var(--bg-page); display: flex; justify-content: center; align-items: flex-start;
min-height: 100vh; padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
color: var(--text-primary);
}
.phone-shell {
width: 390px; min-height: 844px; background: var(--bg-content);
border-radius: 44px; box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0;
overflow: hidden; position: relative; display: flex; flex-direction: column;
}
.phone-status-bar {
height: 44px; background: #fff; display: flex; align-items: center;
justify-content: space-between; padding: 0 24px; font-size: 12px; font-weight: 600; flex-shrink: 0;
}
.phone-status-bar .time { font-size: 15px; font-weight: 700; }
.phone-status-bar .icons { display: flex; gap: 6px; align-items: center; }
.nav-bar {
height: 44px; display: flex; align-items: center; padding: 0 16px;
background: #fff; border-bottom: 1px solid #f0f0f0; flex-shrink: 0; position: relative;
}
.nav-back { font-size: 18px; color: var(--text-primary); cursor: pointer; line-height: 1; text-decoration: none; }
.nav-title { position: absolute; left: 50%; transform: translateX(-50%); font-size: 17px; font-weight: 600; }
.scroll-content {
flex: 1; overflow-y: auto;
background: linear-gradient(180deg, #effbfb 0%, #f7f9fb 180px, #f7f9fb 100%);
padding-bottom: 100px;
}
.scroll-content::-webkit-scrollbar { display: none; }
.page-body { padding: 16px; }
/* 步骤条 */
.stepper { display: flex; align-items: center; justify-content: center; margin-bottom: 18px; padding: 16px 0; }
.stepper__item { display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; text-decoration: none; }
.stepper__item--future { cursor: default; pointer-events: none; opacity: 0.45; }
.stepper__dot {
width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700; background: #e8e8e8; color: #bfbfbf; transition: all 0.25s;
}
.stepper__item--done .stepper__dot { background: var(--primary); color: #fff; }
.stepper__item--active { cursor: default; pointer-events: none; }
.stepper__item--active .stepper__dot {
width: 34px; height: 34px; font-size: 14px;
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%); color: #fff;
box-shadow: 0 0 0 6px rgba(19,194,194,0.2);
}
.stepper__label { font-size: 12px; color: var(--text-muted); white-space: nowrap; transition: all 0.2s; }
.stepper__item--done .stepper__label { color: var(--primary-dark); }
.stepper__item--active .stepper__label {
color: var(--primary-dark); font-weight: 700; font-size: 13px;
background: var(--primary-light); padding: 2px 10px; border-radius: 10px;
}
.stepper__item--done:hover .stepper__dot { transform: scale(1.12); box-shadow: 0 0 0 4px rgba(19,194,194,0.15); }
.stepper__item--done:hover .stepper__label { text-decoration: underline; text-underline-offset: 3px; }
.stepper__line { width: 44px; height: 3px; background: #e8e8e8; margin: 0 8px; margin-bottom: 22px; border-radius: 2px; }
.stepper__line--done { background: var(--primary); }
.fee-banner {
background: linear-gradient(135deg, #f0fffe 0%, #f7fffd 100%);
border: 1px solid #d7f5f3; border-radius: 18px; padding: 14px 16px;
display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px;
}
.fee-banner__label { font-size: 13px; color: var(--primary-dark); font-weight: 600; }
.fee-banner__value { font-size: 24px; font-weight: 800; color: var(--primary-dark); }
.section-title { font-size: 16px; font-weight: 700; margin-bottom: 14px; }
.date-card {
background: #fff; border-radius: 20px; padding: 18px 16px; margin-bottom: 14px;
box-shadow: var(--shadow); border: 1px solid rgba(19,194,194,0.06);
}
.calendar-header {
display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px;
}
.calendar-header__month { font-size: 17px; font-weight: 700; color: var(--text-primary); }
.calendar-header__nav {
display: flex; gap: 8px;
}
.calendar-header__btn {
width: 32px; height: 32px; border-radius: 10px; border: 1px solid #e8e8e8;
background: #fff; display: flex; align-items: center; justify-content: center;
cursor: pointer; font-size: 14px; color: var(--text-secondary); transition: all 0.2s;
}
.calendar-header__btn:hover { border-color: var(--primary); color: var(--primary); }
.calendar-header__btn:disabled { opacity: 0.3; pointer-events: none; }
.calendar-weekdays {
display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; margin-bottom: 8px;
}
.calendar-weekday {
text-align: center; font-size: 12px; font-weight: 600; color: var(--text-muted); padding: 6px 0;
}
.calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
.calendar-cell {
display: flex; flex-direction: column; align-items: center; justify-content: center;
height: 52px; border-radius: 12px; cursor: pointer; transition: all 0.2s;
border: 2px solid transparent; background: #f9fbfb; position: relative;
}
.calendar-cell:hover { border-color: var(--primary); }
.calendar-cell.selected { background: var(--primary); border-color: var(--primary); }
.calendar-cell.selected .calendar-cell__day,
.calendar-cell.selected .calendar-cell__slots { color: #fff; }
.calendar-cell.disabled {
opacity: 0.3; pointer-events: none; background: #f5f5f5;
}
.calendar-cell.empty { background: transparent; pointer-events: none; }
.calendar-cell__day { font-size: 15px; font-weight: 700; color: var(--text-primary); line-height: 1; }
.calendar-cell__slots { font-size: 10px; color: var(--primary-dark); font-weight: 600; margin-top: 2px; }
.calendar-cell.disabled .calendar-cell__slots { display: none; }
.calendar-cell__full {
font-size: 10px; color: var(--text-muted); font-weight: 600; margin-top: 2px;
}
.calendar-cell.today .calendar-cell__day {
color: var(--primary-dark);
}
.calendar-cell.today { border-color: rgba(19,194,194,0.3); }
.summary-card {
background: #fff; border-radius: 20px; padding: 18px 16px; margin-bottom: 14px;
box-shadow: var(--shadow); border: 1px solid rgba(19,194,194,0.06);
}
.summary-list { display: flex; flex-direction: column; gap: 14px; }
.summary-item { display: flex; justify-content: space-between; gap: 12px; }
.summary-item__label { font-size: 13px; color: var(--text-muted); flex-shrink: 0; }
.summary-item__value { font-size: 14px; font-weight: 600; text-align: right; line-height: 1.6; }
.person-card {
margin-top: 0; background: linear-gradient(135deg, #ffffff 0%, #f8fffe 100%);
border-radius: 20px; padding: 16px; border: 1px solid #e4f7f5; box-shadow: var(--shadow);
}
.person-card__header { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
.person-card__avatar {
width: 48px; height: 48px; border-radius: 16px;
background: linear-gradient(135deg, #13c2c2 0%, #5cdbd3 100%);
color: #fff; display: flex; align-items: center; justify-content: center;
font-size: 18px; font-weight: 700; flex-shrink: 0;
}
.person-card__name { font-size: 16px; font-weight: 700; margin-bottom: 4px; }
.person-card__meta { font-size: 12px; color: var(--text-muted); }
.info-list { display: flex; flex-direction: column; gap: 14px; }
.info-item { display: flex; justify-content: space-between; gap: 12px; }
.info-item__label { font-size: 13px; color: var(--text-muted); flex-shrink: 0; }
.info-item__value { font-size: 14px; font-weight: 600; text-align: right; line-height: 1.6; }
.bottom-actions {
position: absolute; left: 16px; right: 16px; bottom: 18px;
display: flex; gap: 12px; padding-bottom: 16px;
}
.bottom-actions__btn {
flex: 1; height: 48px; border-radius: 24px; font-size: 15px; font-weight: 600; border: none; cursor: pointer;
}
.bottom-actions__btn--ghost {
background: #f5f5f5; color: var(--text-secondary); border: 1px solid #e8e8e8;
}
.bottom-actions__btn--primary {
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%);
color: #fff; box-shadow: 0 10px 20px rgba(19,194,194,0.22);
}
/* 确认弹窗 */
.modal-overlay {
position: absolute; inset: 0; background: rgba(0,0,0,0.42); z-index: 120;
display: flex; align-items: center; justify-content: center; padding: 24px;
}
.modal-overlay.hidden { display: none; }
.confirm-modal {
width: 100%; background: #fff; border-radius: 24px; padding: 22px 20px 18px;
box-shadow: 0 20px 50px rgba(0,0,0,0.16);
}
.confirm-modal__title {
font-size: 18px; font-weight: 700; color: var(--text-primary); text-align: center; margin-bottom: 8px;
}
.confirm-modal__desc {
font-size: 13px; color: var(--text-muted); text-align: center; line-height: 1.6; margin-bottom: 18px;
}
.confirm-modal__info {
background: #f7f9fb; border-radius: 16px; padding: 14px 16px; margin-bottom: 18px;
}
.confirm-modal__row {
display: flex; justify-content: space-between; align-items: center; font-size: 14px;
color: var(--text-secondary); margin-bottom: 12px;
}
.confirm-modal__row:last-child { margin-bottom: 0; }
.confirm-modal__value { font-size: 15px; font-weight: 700; color: var(--text-primary); }
.confirm-modal__value--highlight { color: var(--primary-dark); }
.confirm-modal__actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.confirm-modal__btn {
height: 44px; border-radius: 22px; font-size: 15px; font-weight: 600; border: none; cursor: pointer;
}
.confirm-modal__btn--ghost { background: #f5f5f5; color: var(--text-secondary); }
.confirm-modal__btn--primary {
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%); color: #fff;
}
.toast {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%) scale(0.9);
background: rgba(0,0,0,0.75); color: #fff; padding: 14px 22px; border-radius: 12px;
font-size: 13px; line-height: 1.6; text-align: center; z-index: 200;
opacity: 0; pointer-events: none; transition: all 0.25s ease; max-width: 280px;
}
.toast.show { opacity: 1; transform: translate(-50%, -50%) scale(1); }
</style>
</head>
<body>
<div class="phone-shell">
<div class="phone-status-bar">
<span class="time">12:00</span>
<div class="icons">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<div class="nav-bar">
<a class="nav-back" href="index.html">&#8249;</a>
<span class="nav-title">体检预约</span>
</div>
<div class="scroll-content">
<div class="page-body">
<!-- [交互] 步骤条可点击跳转 -->
<div class="stepper">
<!-- [交互] 已提交项目,点击步骤一弹提示,不跳转 -->
<div class="stepper__item stepper__item--done" onclick="showToast()"><div class="stepper__dot">&#10003;</div><div class="stepper__label">选择医院</div></div>
<div class="stepper__line stepper__line--done"></div>
<a class="stepper__item stepper__item--done" href="booking-project-selected.html"><div class="stepper__dot">&#10003;</div><div class="stepper__label">选择项目</div></a>
<div class="stepper__line stepper__line--done"></div>
<a class="stepper__item stepper__item--active" href="booking-date.html"><div class="stepper__dot">3</div><div class="stepper__label">预约日期</div></a>
</div>
<!-- <div class="fee-banner">-->
<!-- <span class="fee-banner__label">您本年度的体检费用(元)</span>-->
<!-- <span class="fee-banner__value">1500.00</span>-->
<!-- </div>-->
<div class="summary-card">
<div class="section-title">体检信息</div>
<div class="summary-list">
<div class="summary-item"><div class="summary-item__label">体检医院</div><div class="summary-item__value">宁夏宝石花医院</div></div>
<div class="summary-item"><div class="summary-item__label">医院地址</div><div class="summary-item__value">银川市西夏区怀远路148号</div></div>
<!-- <div class="summary-item"><div class="summary-item__label">预计费用</div><div class="summary-item__value">&yen;680</div></div>-->
</div>
</div>
<div class="date-card">
<div class="calendar-header">
<span class="calendar-header__month" id="calendarMonth"></span>
<div class="calendar-header__nav">
<button class="calendar-header__btn" id="prevMonth">&#8249;</button>
<button class="calendar-header__btn" id="nextMonth">&#8250;</button>
</div>
</div>
<div class="calendar-weekdays">
<span class="calendar-weekday"></span>
<span class="calendar-weekday"></span>
<span class="calendar-weekday"></span>
<span class="calendar-weekday"></span>
<span class="calendar-weekday"></span>
<span class="calendar-weekday"></span>
<span class="calendar-weekday"></span>
</div>
<div class="calendar-grid" id="calendarGrid"></div>
</div>
</div>
</div>
<div class="bottom-actions">
<button class="bottom-actions__btn bottom-actions__btn--ghost" onclick="window.location.href='booking-project-selected.html'">上一步</button>
<button class="bottom-actions__btn bottom-actions__btn--primary" onclick="openConfirm()">确定预约</button>
</div>
<!-- [交互] 预约二次确认弹窗 -->
<div class="modal-overlay hidden" id="confirmModal">
<div class="confirm-modal">
<div class="confirm-modal__title">确认预约信息</div>
<div class="confirm-modal__desc">请确认以下预约信息无误</div>
<div class="confirm-modal__info">
<div class="confirm-modal__row">
<span>体检医院</span>
<span class="confirm-modal__value">宁夏宝石花医院</span>
</div>
<div class="confirm-modal__row">
<span>体检项目数</span>
<span class="confirm-modal__value" id="confirmCount">9 项</span>
</div>
<div class="confirm-modal__row">
<span>体检日期</span>
<span class="confirm-modal__value confirm-modal__value--highlight" id="confirmDate">--</span>
</div>
</div>
<div class="confirm-modal__actions">
<button class="confirm-modal__btn confirm-modal__btn--ghost" onclick="closeConfirm()">取消</button>
<button class="confirm-modal__btn confirm-modal__btn--primary" onclick="window.location.href='booking-date-selected.html'">确认预约</button>
</div>
</div>
</div>
<div class="toast" id="stepToast">当前已提交项目,如需重新选择医院,请先点击下方重选项目按钮</div>
</div>
<script>
const today = new Date(2026, 2, 16); // 2026-03-16
let currentYear = today.getFullYear();
let currentMonth = today.getMonth();
let selectedDate = null;
const grid = document.getElementById('calendarGrid');
const monthLabel = document.getElementById('calendarMonth');
const prevBtn = document.getElementById('prevMonth');
const nextBtn = document.getElementById('nextMonth');
let toastTimer = null;
function showToast() {
const toast = document.getElementById('stepToast');
toast.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toast.classList.remove('show');
}, 2200);
}
// 模拟可约数据:工作日可约,周末和已过日期不可约
function getSlots(year, month, day) {
const d = new Date(year, month, day);
const dow = d.getDay();
// 已过日期
if (d < new Date(today.getFullYear(), today.getMonth(), today.getDate())) return -1;
// 周末不可约
if (dow === 0 || dow === 6) return 0;
// 模拟剩余人数(用日期做伪随机)
const seed = (day * 7 + month * 13) % 20;
if (seed < 3) return 0; // 约满
return seed + 2;
}
function renderCalendar() {
grid.innerHTML = '';
monthLabel.textContent = `${currentYear}${currentMonth + 1}`;
// 不允许切换到当月之前
prevBtn.disabled = (currentYear === today.getFullYear() && currentMonth <= today.getMonth());
const firstDay = new Date(currentYear, currentMonth, 1);
// 周一为起始(getDay: 0=周日,转为周一起始)
let startDow = firstDay.getDay();
startDow = startDow === 0 ? 6 : startDow - 1;
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate();
// 填充前置空格
for (let i = 0; i < startDow; i++) {
const empty = document.createElement('div');
empty.className = 'calendar-cell empty';
grid.appendChild(empty);
}
for (let day = 1; day <= daysInMonth; day++) {
const cell = document.createElement('div');
const slots = getSlots(currentYear, currentMonth, day);
const isToday = (currentYear === today.getFullYear() && currentMonth === today.getMonth() && day === today.getDate());
const isDisabled = slots <= 0;
const isFull = slots === 0;
const isPast = slots === -1;
let cls = 'calendar-cell';
if (isToday) cls += ' today';
if (isDisabled) cls += ' disabled';
if (selectedDate && selectedDate.y === currentYear && selectedDate.m === currentMonth && selectedDate.d === day) {
cls += ' selected';
}
cell.className = cls;
let slotsHtml = '';
if (isPast) {
slotsHtml = '';
} else if (isFull) {
slotsHtml = '<span class="calendar-cell__full">不可约</span>';
} else if (slots > 0) {
slotsHtml = `<span class="calendar-cell__slots">余${slots}</span>`;
}
cell.innerHTML = `<span class="calendar-cell__day">${day}</span>${slotsHtml}`;
if (!isDisabled) {
cell.addEventListener('click', () => {
selectedDate = { y: currentYear, m: currentMonth, d: day };
renderCalendar();
});
}
grid.appendChild(cell);
}
}
prevBtn.addEventListener('click', () => {
currentMonth--;
if (currentMonth < 0) { currentMonth = 11; currentYear--; }
renderCalendar();
});
nextBtn.addEventListener('click', () => {
currentMonth++;
if (currentMonth > 11) { currentMonth = 0; currentYear++; }
renderCalendar();
});
// 默认选中今天
selectedDate = { y: today.getFullYear(), m: today.getMonth(), d: today.getDate() };
renderCalendar();
function openConfirm() {
if (!selectedDate) return;
const dateStr = `${selectedDate.y}${selectedDate.m + 1}${selectedDate.d}`;
document.getElementById('confirmDate').textContent = dateStr;
document.getElementById('confirmModal').classList.remove('hidden');
}
function closeConfirm() {
document.getElementById('confirmModal').classList.add('hidden');
}
</script>
</body>
</html>
@@ -0,0 +1,442 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>体检预约</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: #13c2c2;
--primary-dark: #0f9b9b;
--primary-light: #e6fffb;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--text-muted: #8c8c8c;
--border: #e8f3f3;
--bg-page: #f0f2f5;
--bg-content: #f7f9fb;
--shadow: 0 10px 30px rgba(19, 194, 194, 0.08);
}
body {
background: var(--bg-page);
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
color: var(--text-primary);
}
.phone-shell {
width: 390px;
min-height: 844px;
background: var(--bg-content);
border-radius: 44px;
box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
}
.phone-status-bar {
height: 44px;
background: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.phone-status-bar .time { font-size: 15px; font-weight: 700; }
.phone-status-bar .icons { display: flex; gap: 6px; align-items: center; }
.nav-bar {
height: 44px;
display: flex;
align-items: center;
padding: 0 16px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
position: relative;
}
.nav-back {
font-size: 18px;
color: var(--text-primary);
cursor: pointer;
line-height: 1;
text-decoration: none;
}
.nav-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 17px;
font-weight: 600;
color: var(--text-primary);
}
.scroll-content {
flex: 1;
overflow-y: auto;
background: linear-gradient(180deg, #effbfb 0%, #f7f9fb 180px, #f7f9fb 100%);
padding-bottom: 120px;
}
.scroll-content::-webkit-scrollbar { display: none; }
.page-body { padding: 16px; }
/* 步骤条 */
.stepper { display: flex; align-items: center; justify-content: center; margin-bottom: 18px; padding: 16px 0; }
.stepper__item { display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; text-decoration: none; }
.stepper__item--future { cursor: default; pointer-events: none; opacity: 0.45; }
.stepper__dot {
width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700; background: #e8e8e8; color: #bfbfbf; transition: all 0.25s;
}
.stepper__item--done .stepper__dot { background: var(--primary); color: #fff; }
.stepper__item--active { cursor: default; pointer-events: none; }
.stepper__item--active .stepper__dot {
width: 34px; height: 34px; font-size: 14px;
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%); color: #fff;
box-shadow: 0 0 0 6px rgba(19,194,194,0.2);
}
.stepper__label { font-size: 12px; color: var(--text-muted); white-space: nowrap; transition: all 0.2s; }
.stepper__item--done .stepper__label { color: var(--primary-dark); }
.stepper__item--active .stepper__label {
color: var(--primary-dark); font-weight: 700; font-size: 13px;
background: var(--primary-light); padding: 2px 10px; border-radius: 10px;
}
.stepper__item--done:hover .stepper__dot { transform: scale(1.12); box-shadow: 0 0 0 4px rgba(19,194,194,0.15); }
.stepper__item--done:hover .stepper__label { text-decoration: underline; text-underline-offset: 3px; }
.stepper__line { width: 44px; height: 3px; background: #e8e8e8; margin: 0 8px; margin-bottom: 22px; border-radius: 2px; }
.stepper__line--done { background: var(--primary); }
.fee-card {
background: linear-gradient(135deg, #f0fffe 0%, #f7fffd 100%);
border: 1px solid #d7f5f3;
border-radius: 18px;
padding: 16px 18px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.fee-card__label {
font-size: 13px;
color: var(--primary-dark);
font-weight: 600;
}
.fee-card__amount {
font-size: 26px;
font-weight: 800;
color: var(--primary-dark);
}
.section-title {
font-size: 16px;
font-weight: 700;
margin-bottom: 14px;
margin-top: 6px;
}
.hospital-card {
background: #fff;
border-radius: 18px;
padding: 16px;
margin-bottom: 12px;
border: 2px solid transparent;
box-shadow: var(--shadow);
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
transition: all 0.25s ease;
}
.hospital-card.active {
border-color: var(--primary);
background: var(--primary-light);
}
.hospital-card__info {
display: flex;
align-items: center;
gap: 12px;
}
.hospital-card__icon {
width: 42px;
height: 42px;
border-radius: 14px;
background: linear-gradient(135deg, #e6fffb 0%, #b5f5ec 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
}
.hospital-card__name {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 2px;
}
.hospital-card__addr {
font-size: 12px;
color: var(--text-muted);
}
.radio-dot {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid #d9d9d9;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.25s ease;
flex-shrink: 0;
}
.hospital-card.active .radio-dot {
border-color: var(--primary);
background: var(--primary);
}
.radio-dot::after {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
background: #fff;
opacity: 0;
transition: opacity 0.25s ease;
}
.hospital-card.active .radio-dot::after { opacity: 1; }
.person-card {
margin-top: 20px;
background: linear-gradient(135deg, #ffffff 0%, #f8fffe 100%);
border-radius: 20px;
padding: 16px;
border: 1px solid #e4f7f5;
box-shadow: var(--shadow);
}
.person-card__header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.person-card__avatar {
width: 48px;
height: 48px;
border-radius: 16px;
background: linear-gradient(135deg, #13c2c2 0%, #5cdbd3 100%);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 700;
flex-shrink: 0;
}
.person-card__name {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 4px;
}
.person-card__meta {
font-size: 12px;
color: var(--text-muted);
}
.info-list { display: flex; flex-direction: column; gap: 14px; }
.info-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.info-item__label { font-size: 13px; color: var(--text-muted); }
.info-item__value { font-size: 14px; color: var(--text-primary); font-weight: 600; }
.bottom-actions {
position: absolute;
left: 16px;
right: 16px;
bottom: 18px;
padding-bottom: 16px;
}
.bottom-actions__btn {
width: 100%;
height: 48px;
border-radius: 24px;
font-size: 15px;
font-weight: 600;
border: none;
cursor: pointer;
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%);
color: #fff;
box-shadow: 0 10px 20px rgba(19, 194, 194, 0.22);
}
.nav-right {
position: absolute; right: 16px; font-size: 13px; color: var(--primary);
font-weight: 600; cursor: pointer; display: flex; align-items: center; gap: 4px;
}
/* 弹窗 */
.modal-overlay {
position: absolute; inset: 0; background: rgba(0,0,0,0.45); z-index: 100;
display: flex; align-items: center; justify-content: center;
transition: opacity 0.25s ease;
}
.modal-overlay.hidden { display: none; }
.modal-card {
width: 340px; background: #fff; border-radius: 24px; padding: 24px 20px;
box-shadow: 0 20px 60px rgba(0,0,0,0.18); position: relative;
}
.modal-card__title {
font-size: 18px; font-weight: 700; color: var(--text-primary);
text-align: center; margin-bottom: 6px;
}
.modal-card__sub {
font-size: 13px; color: var(--text-muted); text-align: center; margin-bottom: 20px;
}
.modal-field {
display: flex; justify-content: space-between; align-items: center;
padding: 14px 0; border-bottom: 1px solid #f0f0f0;
}
.modal-field:last-of-type { border-bottom: none; }
.modal-field__label { font-size: 14px; color: var(--text-muted); }
.modal-field__value { font-size: 14px; color: var(--text-primary); font-weight: 600; }
.marriage-toggle {
display: inline-flex; background: #f5f5f5; border-radius: 20px; overflow: hidden;
}
.marriage-toggle__btn {
padding: 6px 16px; font-size: 13px; font-weight: 600; border: none;
background: transparent; color: var(--text-muted); cursor: pointer;
border-radius: 20px; transition: all 0.2s ease;
}
.marriage-toggle__btn.active {
background: var(--primary); color: #fff;
}
.modal-confirm-btn {
margin-top: 20px; width: 100%; height: 44px; border-radius: 22px;
font-size: 15px; font-weight: 600; border: none; cursor: pointer;
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%);
color: #fff; box-shadow: 0 8px 20px rgba(19, 194, 194, 0.2);
}
</style>
</head>
<body>
<div class="phone-shell">
<div class="phone-status-bar">
<span class="time">12:00</span>
<div class="icons">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<div class="nav-bar">
<a class="nav-back" href="index.html">&#8249;</a>
<span class="nav-title">体检预约</span>
<!-- [交互] 点击打开个人信息弹窗 -->
<span class="nav-right" onclick="openModal()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
个人信息
</span>
</div>
<div class="scroll-content">
<div class="page-body">
<!-- 步骤条 -->
<!-- [交互] 步骤条可点击跳转 -->
<div class="stepper">
<a class="stepper__item stepper__item--active" href="booking-hospital.html"><div class="stepper__dot">1</div><div class="stepper__label">选择医院</div></a>
<div class="stepper__line"></div>
<div class="stepper__item stepper__item--future"><div class="stepper__dot">2</div><div class="stepper__label">选择项目</div></div>
<div class="stepper__line"></div>
<div class="stepper__item stepper__item--future"><div class="stepper__dot">3</div><div class="stepper__label">预约日期</div></div>
</div>
<!-- 费用展示 -->
<div class="fee-card">
<div class="fee-card__label">您本年度的体检费用(元)</div>
<div class="fee-card__amount">1500.00</div>
</div>
<!-- 医院列表 -->
<div class="section-title">选择医院</div>
<div class="hospital-card active" onclick="selectHospital(this)">
<div class="hospital-card__info">
<div class="hospital-card__icon">🏥</div>
<div><div class="hospital-card__name">宁夏宝石花医院</div><div class="hospital-card__addr">宁夏银川市</div></div>
</div>
<div class="radio-dot"></div>
</div>
<div class="hospital-card" onclick="selectHospital(this)">
<div class="hospital-card__info">
<div class="hospital-card__icon">🏥</div>
<div><div class="hospital-card__name">长庆星龙园医院</div><div class="hospital-card__addr">陕西西安市</div></div>
</div>
<div class="radio-dot"></div>
</div>
<div class="hospital-card" onclick="selectHospital(this)">
<div class="hospital-card__info">
<div class="hospital-card__icon">🏥</div>
<div><div class="hospital-card__name">西安长庆医院</div><div class="hospital-card__addr">陕西西安市</div></div>
</div>
<div class="radio-dot"></div>
</div>
</div>
</div>
<div class="bottom-actions">
<button class="bottom-actions__btn" onclick="window.location.href='booking-project.html'">下一步</button>
</div>
<!-- [交互] 个人信息确认弹窗,仅从 booking-type 进入时自动弹出 -->
<div class="modal-overlay hidden" id="personModal">
<div class="modal-card">
<div class="modal-card__title">确认个人信息</div>
<div class="modal-card__sub">请核对以下信息,确认无误后继续预约</div>
<div class="modal-field">
<span class="modal-field__label">姓名</span>
<span class="modal-field__value">刘韵琴</span>
</div>
<div class="modal-field">
<span class="modal-field__label">单位</span>
<span class="modal-field__value">长庆油田第一采油厂</span>
</div>
<div class="modal-field">
<span class="modal-field__label">身份证号</span>
<span class="modal-field__value">6101021994********</span>
</div>
<div class="modal-field">
<span class="modal-field__label">性别</span>
<span class="modal-field__value"></span>
</div>
<div class="modal-field">
<span class="modal-field__label">婚姻状况</span>
<div class="marriage-toggle">
<button class="marriage-toggle__btn active" onclick="toggleMarriage(this)">已婚</button>
<button class="marriage-toggle__btn" onclick="toggleMarriage(this)">未婚</button>
</div>
</div>
<button class="modal-confirm-btn" onclick="closeModal()">确认</button>
</div>
</div>
</div>
<script>
// [交互] 仅从 booking-type 跳转进入时自动弹出个人信息确认弹窗
if (new URLSearchParams(window.location.search).get('from') === 'booking-type') {
document.getElementById('personModal').classList.remove('hidden');
}
function selectHospital(el) {
document.querySelectorAll('.hospital-card').forEach(c => c.classList.remove('active'));
el.classList.add('active');
}
function openModal() {
document.getElementById('personModal').classList.remove('hidden');
}
function closeModal() {
document.getElementById('personModal').classList.add('hidden');
}
function toggleMarriage(btn) {
document.querySelectorAll('.marriage-toggle__btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
}
</script>
</body>
</html>
@@ -0,0 +1,290 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>体检预约</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: #13c2c2; --primary-dark: #0f9b9b; --primary-light: #e6fffb;
--text-primary: #1a1a1a; --text-secondary: #666666; --text-muted: #8c8c8c;
--bg-page: #f0f2f5; --bg-content: #f7f9fb;
--shadow: 0 10px 30px rgba(19, 194, 194, 0.08);
}
body {
background: var(--bg-page); display: flex; justify-content: center; align-items: flex-start;
min-height: 100vh; padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
color: var(--text-primary);
}
.phone-shell {
width: 390px; min-height: 844px; background: var(--bg-content);
border-radius: 44px; box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0;
overflow: hidden; position: relative; display: flex; flex-direction: column;
}
.phone-status-bar {
height: 44px; background: #fff; display: flex; align-items: center;
justify-content: space-between; padding: 0 24px; font-size: 12px; font-weight: 600; flex-shrink: 0;
}
.phone-status-bar .time { font-size: 15px; font-weight: 700; }
.phone-status-bar .icons { display: flex; gap: 6px; align-items: center; }
.nav-bar {
height: 44px; display: flex; align-items: center; padding: 0 16px;
background: #fff; border-bottom: 1px solid #f0f0f0; flex-shrink: 0; position: relative;
}
.nav-back { font-size: 18px; color: var(--text-primary); cursor: pointer; line-height: 1; text-decoration: none; }
.nav-title { position: absolute; left: 50%; transform: translateX(-50%); font-size: 17px; font-weight: 600; }
.scroll-content {
flex: 1; overflow-y: auto;
background: linear-gradient(180deg, #effbfb 0%, #f7f9fb 180px, #f7f9fb 100%);
padding-bottom: 100px;
}
.scroll-content::-webkit-scrollbar { display: none; }
.page-body { padding: 16px; }
/* 步骤条 */
.stepper { display: flex; align-items: center; justify-content: center; margin-bottom: 18px; padding: 16px 0; }
.stepper__item { display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; text-decoration: none; }
.stepper__item--future { cursor: default; pointer-events: none; opacity: 0.45; }
.stepper__dot {
width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700; background: #e8e8e8; color: #bfbfbf; transition: all 0.25s;
}
.stepper__item--done .stepper__dot { background: var(--primary); color: #fff; }
.stepper__item--active { cursor: default; pointer-events: none; }
.stepper__item--active .stepper__dot {
width: 34px; height: 34px; font-size: 14px;
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%); color: #fff;
box-shadow: 0 0 0 6px rgba(19,194,194,0.2);
}
.stepper__label { font-size: 12px; color: var(--text-muted); white-space: nowrap; transition: all 0.2s; }
.stepper__item--done .stepper__label { color: var(--primary-dark); }
.stepper__item--active .stepper__label {
color: var(--primary-dark); font-weight: 700; font-size: 13px;
background: var(--primary-light); padding: 2px 10px; border-radius: 10px;
}
.stepper__item--done:hover .stepper__dot { transform: scale(1.12); box-shadow: 0 0 0 4px rgba(19,194,194,0.15); }
.stepper__item--done:hover .stepper__label { text-decoration: underline; text-underline-offset: 3px; }
.stepper__line { width: 44px; height: 3px; background: #e8e8e8; margin: 0 8px; margin-bottom: 22px; border-radius: 2px; }
.stepper__line--done { background: var(--primary); }
.fee-banner {
background: linear-gradient(135deg, #f0fffe 0%, #f7fffd 100%);
border: 1px solid #d7f5f3; border-radius: 18px; padding: 14px 16px;
display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px;
}
.fee-banner__label { font-size: 13px; color: var(--primary-dark); font-weight: 600; }
.fee-banner__value { font-size: 24px; font-weight: 800; color: var(--primary-dark); }
.section-title { font-size: 16px; font-weight: 700; margin-bottom: 14px; }
/* 基本信息卡片 */
.info-card {
background: #fff; border-radius: 20px; padding: 18px 16px; margin-bottom: 14px;
box-shadow: var(--shadow); border: 1px solid rgba(19,194,194,0.06);
}
.info-card__row {
display: flex; justify-content: space-between; align-items: center;
padding: 12px 0; border-bottom: 1px solid #f0f0f0;
}
.info-card__row:first-child { padding-top: 0; }
.info-card__row:last-child { border-bottom: none; padding-bottom: 0; }
.info-card__label { font-size: 13px; color: var(--text-muted); }
.info-card__value { font-size: 14px; font-weight: 600; color: var(--text-primary); text-align: right; }
.info-card__value--highlight { color: var(--primary-dark); font-size: 18px; font-weight: 800; }
.info-card__sub {
display: flex; gap: 16px; justify-content: flex-end; margin-top: 4px;
}
.info-card__sub-item { font-size: 12px; color: var(--text-muted); }
.info-card__sub-value { font-weight: 600; color: var(--text-secondary); }
/* 项目列表区 */
.project-section {
background: #fff; border-radius: 20px; padding: 18px 16px;
box-shadow: var(--shadow); border: 1px solid rgba(19,194,194,0.06);
}
.project-section__header {
display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px;
}
.project-section__count { font-size: 13px; color: var(--text-muted); }
.project-section__count em { font-style: normal; color: var(--primary-dark); font-weight: 700; }
.project-tabs {
display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px;
padding: 5px; background: #f5f7f7; border-radius: 14px; margin-bottom: 14px;
}
.project-tab {
height: 34px; border: none; border-radius: 10px; background: transparent;
color: var(--text-secondary); font-size: 13px; font-weight: 600; cursor: pointer;
}
.project-tab--active {
background: #fff; color: var(--primary-dark);
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.project-list-panel {
max-height: 280px; overflow-y: auto; padding-right: 2px;
}
.project-list-panel::-webkit-scrollbar { display: none; }
.project-list { display: flex; flex-direction: column; gap: 10px; }
.project-item {
display: flex; align-items: center; gap: 12px; padding: 12px 14px;
background: #f9fbfb; border-radius: 14px; border: 1px solid #eef5f5;
}
.project-item__dot {
width: 8px; height: 8px; border-radius: 50%; background: var(--primary); flex-shrink: 0;
}
.project-item__info { flex: 1; }
.project-item__name { font-size: 14px; font-weight: 600; margin-bottom: 2px; }
.project-item__desc { font-size: 12px; color: var(--text-muted); }
.project-item__price { font-size: 13px; font-weight: 700; color: var(--primary-dark); flex-shrink: 0; }
.project-item__badge {
display: inline-flex; align-items: center; height: 20px; padding: 0 8px;
border-radius: 10px; font-size: 10px; font-weight: 600; flex-shrink: 0;
}
.project-item__badge--normal { background: #f5f5f5; color: var(--text-muted); }
.project-item__badge--key { background: #fff1f0; color: #ff4d4f; }
.bottom-actions {
position: absolute; left: 16px; right: 16px; bottom: 18px;
display: flex; gap: 12px; padding-bottom: 16px;
}
.bottom-actions__btn {
flex: 1; height: 48px; border-radius: 24px; font-size: 15px; font-weight: 600; border: none; cursor: pointer;
}
.bottom-actions__btn--ghost {
background: #f5f5f5; color: var(--text-secondary); border: 1px solid #e8e8e8;
}
.bottom-actions__btn--primary {
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%);
color: #fff; box-shadow: 0 10px 20px rgba(19,194,194,0.22);
}
/* Toast 提示 */
.toast {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%) scale(0.9);
background: rgba(0,0,0,0.75); color: #fff; padding: 14px 22px; border-radius: 12px;
font-size: 13px; line-height: 1.6; text-align: center; z-index: 200;
opacity: 0; pointer-events: none; transition: all 0.25s ease; max-width: 280px;
}
.toast.show { opacity: 1; transform: translate(-50%, -50%) scale(1); }
</style>
</head>
<body>
<div class="phone-shell">
<div class="phone-status-bar">
<span class="time">12:00</span>
<div class="icons">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<div class="nav-bar">
<a class="nav-back" href="index.html">&#8249;</a>
<span class="nav-title">体检预约</span>
</div>
<div class="scroll-content">
<div class="page-body">
<!-- [交互] 步骤条可点击跳转 -->
<div class="stepper">
<!-- [交互] 已提交项目,点击步骤一弹提示,不跳转 -->
<div class="stepper__item stepper__item--done" onclick="showToast()"><div class="stepper__dot">&#10003;</div><div class="stepper__label">选择医院</div></div>
<div class="stepper__line stepper__line--done"></div>
<a class="stepper__item stepper__item--active" href="booking-project.html"><div class="stepper__dot">&#10003;</div><div class="stepper__label">选择项目</div></a>
<div class="stepper__line"></div>
<div class="stepper__item stepper__item--future"><div class="stepper__dot">3</div><div class="stepper__label">预约日期</div></div>
</div>
<!-- 基本信息卡片 -->
<div class="info-card">
<div class="info-card__row">
<span class="info-card__label">承检医院</span>
<span class="info-card__value">宁夏宝石花医院</span>
</div>
<div class="info-card__row">
<span class="info-card__label">体检额度</span>
<span class="info-card__value info-card__value--highlight" >¥1500.00</span>
</div>
<div class="info-card__row">
<span class="info-card__label">已选费用合计</span>
<span class="info-card__value info-card__value--highlight" id="totalFeeDisplay">¥860.00</span>
</div>
<div class="info-card__row" style="flex-wrap:wrap">
<span class="info-card__label">费用包含</span>
<div class="info-card__sub">
<span class="info-card__sub-item">必检 <span class="info-card__sub-value" id="requiredFeeDisplay">¥720.00</span></span>
<span class="info-card__sub-item">选检 <span class="info-card__sub-value" id="optionalFeeDisplay">¥140.00</span></span>
</div>
</div>
</div>
<!-- 体检项目列表 -->
<div class="project-section">
<div class="project-section__header">
<div class="section-title" style="margin-bottom:0">已选体检项目</div>
<span class="project-section__count"><em id="totalCount">9</em></span>
</div>
<!-- [交互] 点击切换自选项目/必检项目 -->
<div class="project-tabs">
<button class="project-tab project-tab--active" type="button" data-tab="optional">自选项目(1)</button>
<button class="project-tab" type="button" data-tab="required">必检项目(8)</button>
</div>
<div class="project-list-panel">
<div class="project-list" id="projectList"></div>
</div>
</div>
</div>
</div>
<div class="bottom-actions">
<button class="bottom-actions__btn bottom-actions__btn--ghost" onclick="window.location.href='booking-project.html'">重选项目</button>
<button class="bottom-actions__btn bottom-actions__btn--primary" onclick="window.location.href='booking-date.html'">下一步</button>
</div>
<div class="toast" id="stepToast">当前已提交项目,如需重新选择医院,请先点击下方重选项目按钮</div>
</div>
<script>
const projects = [
{ name: '一般检查', desc: '身高、体重、血压、视力', price: 120, type: 'required', level: 'normal' },
{ name: '血常规', desc: '血红蛋白、白细胞、血小板等', price: 80, type: 'required', level: 'normal' },
{ name: '尿常规', desc: '尿蛋白、尿糖、尿潜血等', price: 45, type: 'required', level: 'normal' },
{ name: '肝功能', desc: 'ALT、AST、总胆红素等', price: 160, type: 'required', level: 'key' },
{ name: '肾功能', desc: '肌酐、尿素氮、尿酸等', price: 110, type: 'required', level: 'key' },
{ name: '血脂四项', desc: '总胆固醇、甘油三酯、HDL、LDL', price: 90, type: 'required', level: 'normal' },
{ name: '空腹血糖', desc: '空腹静脉血糖检测', price: 40, type: 'required', level: 'normal' },
{ name: '心电图', desc: '十二导联心电图检查', price: 75, type: 'required', level: 'key' },
{ name: '胸部X光', desc: '胸部正位X线摄影', price: 140, type: 'optional', level: 'normal' }
];
let activeTab = 'optional';
const listEl = document.getElementById('projectList');
const tabButtons = document.querySelectorAll('.project-tab');
let toastTimer = null;
function showToast() {
const toast = document.getElementById('stepToast');
toast.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toast.classList.remove('show');
}, 2200);
}
function renderList() {
const items = projects.filter(p => p.type === activeTab);
listEl.innerHTML = items.map(p => `
<div class="project-item">
<div class="project-item__dot"></div>
<div class="project-item__info">
<div class="project-item__name">${p.name}</div>
<div class="project-item__desc">${p.desc}</div>
</div>
<span class="project-item__badge project-item__badge--${p.level === 'key' ? 'key' : 'normal'}">${p.level === 'key' ? '重点' : '一般'}</span>
<span class="project-item__price">¥${p.price.toFixed(2)}</span>
</div>
`).join('');
}
tabButtons.forEach(btn => {
btn.addEventListener('click', () => {
activeTab = btn.dataset.tab;
tabButtons.forEach(b => b.classList.remove('project-tab--active'));
btn.classList.add('project-tab--active');
renderList();
});
});
renderList();
</script>
</body>
</html>
@@ -0,0 +1,453 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>体检预约</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: #13c2c2;
--primary-dark: #0f9b9b;
--primary-light: #e6fffb;
--success-light: #f6ffed;
--success-text: #52c41a;
--danger: #ff4d4f;
--danger-light: #fff1f0;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--text-muted: #8c8c8c;
--border: #e8f3f3;
--bg-page: #f0f2f5;
--bg-content: #f7f9fb;
--shadow: 0 10px 30px rgba(19, 194, 194, 0.08);
}
body {
background: var(--bg-page);
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
color: var(--text-primary);
}
.phone-shell {
width: 390px; min-height: 844px; background: var(--bg-content);
border-radius: 44px; box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0;
overflow: hidden; position: relative; display: flex; flex-direction: column;
}
.phone-status-bar {
height: 44px; background: #fff; display: flex; align-items: center;
justify-content: space-between; padding: 0 24px; font-size: 12px; font-weight: 600; flex-shrink: 0;
}
.phone-status-bar .time { font-size: 15px; font-weight: 700; }
.phone-status-bar .icons { display: flex; gap: 6px; align-items: center; }
.nav-bar {
height: 44px; display: flex; align-items: center; padding: 0 16px;
background: #fff; border-bottom: 1px solid #f0f0f0; flex-shrink: 0; position: relative;
}
.nav-back { font-size: 18px; color: var(--text-primary); cursor: pointer; line-height: 1; text-decoration: none; }
.nav-title { position: absolute; left: 50%; transform: translateX(-50%); font-size: 17px; font-weight: 600; color: var(--text-primary); }
.scroll-content {
flex: 1; overflow-y: auto;
background: linear-gradient(180deg, #effbfb 0%, #f7f9fb 180px, #f7f9fb 100%);
padding-bottom: 112px;
}
.scroll-content::-webkit-scrollbar { display: none; }
.page-body { padding: 16px; }
/* 步骤条 */
.stepper { display: flex; align-items: center; justify-content: center; margin-bottom: 18px; padding: 16px 0; }
.stepper__item { display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; text-decoration: none; }
.stepper__item--future { cursor: default; pointer-events: none; opacity: 0.45; }
.stepper__dot {
width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700; background: #e8e8e8; color: #bfbfbf; transition: all 0.25s;
}
.stepper__item--done .stepper__dot { background: var(--primary); color: #fff; }
.stepper__item--active { cursor: default; pointer-events: none; }
.stepper__item--active .stepper__dot {
width: 34px; height: 34px; font-size: 14px;
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%); color: #fff;
box-shadow: 0 0 0 6px rgba(19,194,194,0.2);
}
.stepper__label { font-size: 12px; color: var(--text-muted); white-space: nowrap; transition: all 0.2s; }
.stepper__item--done .stepper__label { color: var(--primary-dark); }
.stepper__item--active .stepper__label {
color: var(--primary-dark); font-weight: 700; font-size: 13px;
background: var(--primary-light); padding: 2px 10px; border-radius: 10px;
}
.stepper__item--done:hover .stepper__dot { transform: scale(1.12); box-shadow: 0 0 0 4px rgba(19,194,194,0.15); }
.stepper__item--done:hover .stepper__label { text-decoration: underline; text-underline-offset: 3px; }
.stepper__line { width: 44px; height: 3px; background: #e8e8e8; margin: 0 8px; margin-bottom: 22px; border-radius: 2px; }
.stepper__line--done { background: var(--primary); }
/* 费用卡片 */
.fee-banner {
background: linear-gradient(135deg, #f0fffe 0%, #f7fffd 100%);
border: 1px solid #d7f5f3; border-radius: 18px; padding: 14px 16px;
display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px;
}
.fee-banner__label { font-size: 13px; color: var(--primary-dark); font-weight: 600; }
.fee-banner__value { font-size: 24px; font-weight: 800; color: var(--primary-dark); }
/* 项目列表 */
.section-title { font-size: 16px; font-weight: 700; margin-bottom: 14px; }
.project-tabs {
display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px;
padding: 6px; background: rgba(255,255,255,0.92); border-radius: 18px;
box-shadow: 0 8px 20px rgba(19, 194, 194, 0.08); margin-bottom: 14px;
}
.project-tab {
height: 36px; border: none; border-radius: 14px; background: transparent;
color: var(--text-secondary); font-size: 13px; font-weight: 600; cursor: pointer;
}
.project-tab--active {
background: var(--primary-light); color: var(--primary-dark);
box-shadow: inset 0 0 0 1px rgba(19, 194, 194, 0.16);
}
.project-list-panel {
max-height: 432px; overflow-y: auto; padding-right: 2px; margin-bottom: 12px;
}
.project-list-panel::-webkit-scrollbar { display: none; }
.project-grid { display: flex; flex-direction: column; gap: 10px; }
.project-card {
background: #fff; border-radius: 14px; padding: 14px 16px; border: 2px solid #eef5f5;
cursor: pointer; transition: all 0.2s; display: flex; align-items: center; gap: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
}
.project-card:hover { border-color: var(--primary); }
.project-card.selected { border-color: var(--primary); background: var(--primary-light); }
.project-card.project-card--readonly {
cursor: default; background: #fff; border-color: #eef5f5;
}
.project-card.project-card--readonly:hover { border-color: #eef5f5; }
.project-card__check {
width: 22px; height: 22px; border-radius: 6px; border: 2px solid #d9d9d9;
display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: all 0.2s;
}
.project-card__check--hidden { visibility: hidden; }
.project-card__check::after {
content: ''; width: 8px; height: 8px; border-radius: 2px; background: #fff; opacity: 0;
}
.project-card.selected .project-card__check { background: var(--primary); border-color: var(--primary); }
.project-card.selected .project-card__check::after { opacity: 1; }
.project-card__info { flex: 1; min-width: 0; }
.project-card__name { font-size: 14px; font-weight: 600; margin-bottom: 2px; }
.project-card__desc { font-size: 12px; color: var(--text-muted); }
.project-card__side {
flex-shrink: 0; display: flex; align-items: center; gap: 10px;
margin-left: auto; padding-left: 8px;
}
.project-card__tags {
display: flex; flex-direction: column; align-items: flex-end; gap: 6px;
}
.project-card__price { font-size: 13px; color: var(--primary-dark); font-weight: 700; }
.project-card__badge {
display: inline-flex; align-items: center; justify-content: center; height: 22px;
padding: 0 10px; border-radius: 11px; background: #f5f5f5;
color: var(--text-muted); font-size: 11px; font-weight: 600;
}
.project-card__badge--key {
background: #fff1f0; color: #ff4d4f;
}
.project-card__arrow {
flex-shrink: 0; color: #d0d0d0; font-size: 16px; cursor: pointer;
transition: color 0.2s;
}
.project-card__arrow:hover { color: var(--primary); }
/* 底部按钮 */
.bottom-actions {
position: absolute; left: 16px; right: 16px; bottom: 18px;
display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding-bottom: 16px;
}
.summary-card {
background: #fff; border-radius: 18px; padding: 12px 14px;
box-shadow: 0 10px 24px rgba(19, 194, 194, 0.10); border: 1px solid rgba(19, 194, 194, 0.08);
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 12px;
margin-bottom: 14px;
}
.summary-card__title {
display: none;
}
.summary-row {
display: flex; justify-content: space-between; align-items: center;
font-size: 11px; color: var(--text-secondary); margin-bottom: 0; gap: 8px;
}
.summary-row:last-child { margin-bottom: 0; }
.summary-row__value { font-weight: 700; color: var(--text-primary); white-space: nowrap; }
.summary-row__value--highlight { color: var(--primary-dark); font-size: 13px; }
.summary-row__value--danger { color: var(--danger); }
.bottom-actions__btn {
width: 100%; height: 56px; border-radius: 18px; font-size: 15px; font-weight: 600; border: none; cursor: pointer;
}
.bottom-actions__btn--ghost {
background: #fff; color: var(--text-secondary); border: 1px solid #d9e8e8;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.04);
}
.bottom-actions__btn--primary {
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%);
color: #fff; box-shadow: 0 10px 20px rgba(19, 194, 194, 0.22);
}
/* 提交确认弹窗 */
.modal-overlay {
position: absolute; inset: 0; background: rgba(0,0,0,0.42); z-index: 120;
display: flex; align-items: center; justify-content: center; padding: 24px;
}
.modal-overlay.hidden { display: none; }
.confirm-modal {
width: 100%; background: #fff; border-radius: 24px; padding: 22px 20px 18px;
box-shadow: 0 20px 50px rgba(0,0,0,0.16);
}
.confirm-modal__title {
font-size: 18px; font-weight: 700; color: var(--text-primary); text-align: center; margin-bottom: 8px;
}
.confirm-modal__desc {
font-size: 13px; color: var(--text-muted); text-align: center; line-height: 1.6; margin-bottom: 18px;
}
.confirm-modal__info {
background: #f7f9fb; border-radius: 16px; padding: 14px 16px; margin-bottom: 18px;
}
.confirm-modal__row {
display: flex; justify-content: space-between; align-items: center; font-size: 14px;
color: var(--text-secondary); margin-bottom: 12px;
}
.confirm-modal__row:last-child { margin-bottom: 0; }
.confirm-modal__value { font-size: 16px; font-weight: 700; color: var(--text-primary); }
.confirm-modal__value--highlight { color: var(--primary-dark); }
.confirm-modal__actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.confirm-modal__btn {
height: 44px; border-radius: 22px; font-size: 15px; font-weight: 600; border: none; cursor: pointer;
}
.confirm-modal__btn--ghost { background: #f5f5f5; color: var(--text-secondary); }
.confirm-modal__btn--primary {
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%); color: #fff;
}
</style>
</head>
<body>
<div class="phone-shell">
<div class="phone-status-bar">
<span class="time">12:00</span>
<div class="icons">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<div class="nav-bar">
<a class="nav-back" href="index.html">&#8249;</a>
<span class="nav-title">体检预约</span>
</div>
<div class="scroll-content">
<div class="page-body">
<!-- [交互] 步骤条可点击跳转 -->
<div class="stepper">
<a class="stepper__item stepper__item--done" href="booking-hospital.html?from=stepper"><div class="stepper__dot">&#10003;</div><div class="stepper__label">选择医院</div></a>
<div class="stepper__line stepper__line--done"></div>
<a class="stepper__item stepper__item--active" href="booking-project.html"><div class="stepper__dot">2</div><div class="stepper__label">选择项目</div></a>
<div class="stepper__line"></div>
<div class="stepper__item stepper__item--future"><div class="stepper__dot">3</div><div class="stepper__label">预约日期</div></div>
</div>
<!-- 费用 -->
<div class="fee-banner">
<span class="fee-banner__label">您本年度的体检费用(元)</span>
<span class="fee-banner__value">1500.00</span>
</div>
<div class="summary-card">
<div class="summary-card__title">费用汇总</div>
<div class="summary-row">
<span>已选</span>
<span class="summary-row__value summary-row__value--highlight" id="totalFee">¥0.00</span>
</div>
<div class="summary-row">
<span>必检项目</span>
<span class="summary-row__value" id="requiredFee">¥0.00</span>
</div>
<div class="summary-row">
<span>选检项目</span>
<span class="summary-row__value" id="optionalFee">¥0.00</span>
</div>
<div class="summary-row">
<span>可用额度</span>
<span class="summary-row__value" id="availableFee">¥0.00</span>
</div>
</div>
<!-- 项目列表 -->
<div class="section-title">选择体检项目</div>
<!-- [交互] 点击切换必检项目/选检项目 -->
<div class="project-tabs" id="projectTabs">
<button class="project-tab project-tab--active" type="button" data-tab="optional">选检项目</button>
<button class="project-tab" type="button" data-tab="required">必检项目</button>
</div>
<div class="project-list-panel">
<div class="project-grid" id="projectGrid"></div>
</div>
</div>
</div>
<div class="bottom-actions">
<button class="bottom-actions__btn bottom-actions__btn--ghost" type="button" onclick="goPrevStep()">上一步</button>
<button class="bottom-actions__btn bottom-actions__btn--primary" type="button" onclick="openSubmitConfirm()">提交项目</button>
</div>
<!-- [交互] 提交二次确认弹窗 -->
<div class="modal-overlay hidden" id="submitModal">
<div class="confirm-modal">
<div class="confirm-modal__title">确认提交</div>
<div class="confirm-modal__desc">请确认以下体检项目信息无误</div>
<div class="confirm-modal__info">
<div class="confirm-modal__row">
<span>体检项目数</span>
<span class="confirm-modal__value" id="confirmCount">0 项</span>
</div>
<div class="confirm-modal__row">
<span>总体检费用</span>
<span class="confirm-modal__value confirm-modal__value--highlight" id="confirmFee">¥0.00</span>
</div>
</div>
<div class="confirm-modal__actions">
<button class="confirm-modal__btn confirm-modal__btn--ghost" onclick="closeSubmitConfirm()">取消</button>
<button class="confirm-modal__btn confirm-modal__btn--primary" onclick="confirmSubmit()">确认提交</button>
</div>
</div>
</div>
</div>
<script>
const annualLimit = 1500;
const projects = [
{ id: 'general', name: '一般检查', desc: '身高、体重、血压、视力', price: 120, type: 'required', level: 'normal' },
{ id: 'blood', name: '血常规', desc: '血红蛋白、白细胞、血小板等', price: 80, type: 'required', level: 'normal' },
{ id: 'urine', name: '尿常规', desc: '尿蛋白、尿糖、尿潜血等', price: 45, type: 'required', level: 'normal' },
{ id: 'liver', name: '肝功能', desc: 'ALT、AST、总胆红素等', price: 160, type: 'required', level: 'key' },
{ id: 'kidney', name: '肾功能', desc: '肌酐、尿素氮、尿酸等', price: 110, type: 'required', level: 'key' },
{ id: 'lipid', name: '血脂四项', desc: '总胆固醇、甘油三酯、HDL、LDL', price: 90, type: 'required', level: 'normal' },
{ id: 'glucose', name: '空腹血糖', desc: '空腹静脉血糖检测', price: 40, type: 'required', level: 'normal' },
{ id: 'ecg', name: '心电图', desc: '十二导联心电图检查', price: 75, type: 'required', level: 'key' },
{ id: 'xray', name: '胸部X光', desc: '胸部正位X线摄影', price: 140, type: 'optional', level: 'normal' },
{ id: 'ultrasound', name: '腹部B超', desc: '肝胆脾胰肾超声检查', price: 180, type: 'optional', level: 'key' },
{ id: 'thyroid', name: '甲状腺彩超', desc: '甲状腺结构与结节筛查', price: 160, type: 'optional', level: 'key' },
{ id: 'tumor', name: '肿瘤标志物', desc: '基础肿瘤风险筛查', price: 220, type: 'optional', level: 'key' }
];
let activeTab = 'optional';
const selectedOptionalIds = new Set(['xray']);
const grid = document.getElementById('projectGrid');
const tabButtons = document.querySelectorAll('.project-tab');
const totalFeeEl = document.getElementById('totalFee');
const requiredFeeEl = document.getElementById('requiredFee');
const optionalFeeEl = document.getElementById('optionalFee');
const availableFeeEl = document.getElementById('availableFee');
function formatPrice(value) {
return `¥${value.toFixed(2)}`;
}
function getRequiredProjects() {
return projects.filter(project => project.type === 'required');
}
function getOptionalProjects() {
return projects.filter(project => project.type === 'optional');
}
function getRequiredFee() {
return getRequiredProjects().reduce((sum, project) => sum + project.price, 0);
}
function getOptionalFee() {
return getOptionalProjects()
.filter(project => selectedOptionalIds.has(project.id))
.reduce((sum, project) => sum + project.price, 0);
}
function updateSummary() {
const requiredFee = getRequiredFee();
const optionalFee = getOptionalFee();
const totalFee = requiredFee + optionalFee;
const availableFee = annualLimit - totalFee;
requiredFeeEl.textContent = formatPrice(requiredFee);
optionalFeeEl.textContent = formatPrice(optionalFee);
totalFeeEl.textContent = formatPrice(totalFee);
availableFeeEl.textContent = formatPrice(Math.max(availableFee, 0));
availableFeeEl.classList.toggle('summary-row__value--danger', availableFee < 0);
}
function renderProjects() {
const currentProjects = activeTab === 'required' ? getRequiredProjects() : getOptionalProjects();
grid.innerHTML = '';
currentProjects.forEach(project => {
const card = document.createElement('div');
const isReadonly = project.type === 'required';
const isSelected = selectedOptionalIds.has(project.id);
const projectLevelText = project.level === 'key' ? '重点项目' : '一般项目';
card.className = `project-card${isReadonly ? ' project-card--readonly' : ''}${isSelected ? ' selected' : ''}`;
card.innerHTML = `
<div class="project-card__check${isReadonly ? ' project-card__check--hidden' : ''}"></div>
<div class="project-card__info">
<div class="project-card__name">${project.name}</div>
<div class="project-card__desc">${project.desc}</div>
</div>
<div class="project-card__side">
<div class="project-card__tags">
<span class="project-card__price">${formatPrice(project.price)}</span>
<span class="project-card__badge${project.level === 'key' ? ' project-card__badge--key' : ''}">${projectLevelText}</span>
</div>
<span class="project-card__arrow" data-detail="${project.id}">&#8250;</span>
</div>
`;
// [交互] 箭头点击跳转项目详情
card.querySelector('.project-card__arrow').addEventListener('click', (e) => {
e.stopPropagation();
window.location.href = `project-detail.html?id=${project.id}`;
});
if (!isReadonly) {
card.addEventListener('click', () => {
if (selectedOptionalIds.has(project.id)) {
selectedOptionalIds.delete(project.id);
} else {
selectedOptionalIds.add(project.id);
}
renderProjects();
updateSummary();
});
}
grid.appendChild(card);
});
}
tabButtons.forEach(button => {
button.addEventListener('click', () => {
activeTab = button.dataset.tab;
tabButtons.forEach(item => item.classList.remove('project-tab--active'));
button.classList.add('project-tab--active');
renderProjects();
});
});
renderProjects();
updateSummary();
function openSubmitConfirm() {
const requiredCount = getRequiredProjects().length;
const optionalCount = selectedOptionalIds.size;
const totalCount = requiredCount + optionalCount;
const totalFee = getRequiredFee() + getOptionalFee();
document.getElementById('confirmCount').textContent = totalCount + ' 项';
document.getElementById('confirmFee').textContent = formatPrice(totalFee);
document.getElementById('submitModal').classList.remove('hidden');
}
function closeSubmitConfirm() {
document.getElementById('submitModal').classList.add('hidden');
}
function goPrevStep() {
window.location.href = 'booking-hospital.html';
}
function confirmSubmit() {
window.location.href = 'booking-project-selected.html';
}
</script>
</body>
</html>
@@ -0,0 +1,262 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>体检预约</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: #13c2c2;
--primary-dark: #0f9b9b;
--primary-light: #e6fffb;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--text-muted: #8c8c8c;
--border: #e8f3f3;
--bg-page: #f0f2f5;
--bg-content: #f7f9fb;
--shadow: 0 10px 30px rgba(19, 194, 194, 0.08);
}
body {
background: var(--bg-page);
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
color: var(--text-primary);
}
.phone-shell {
width: 390px;
min-height: 844px;
background: var(--bg-content);
border-radius: 44px;
box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
}
.phone-status-bar {
height: 44px;
background: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.phone-status-bar .time { font-size: 15px; font-weight: 700; }
.phone-status-bar .icons { display: flex; gap: 6px; align-items: center; }
.nav-bar {
height: 44px;
display: flex;
align-items: center;
padding: 0 16px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
position: relative;
}
.nav-back {
font-size: 18px;
color: var(--text-primary);
cursor: pointer;
line-height: 1;
text-decoration: none;
}
.nav-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 17px;
font-weight: 600;
color: var(--text-primary);
}
.scroll-content {
flex: 1;
overflow-y: auto;
background: linear-gradient(180deg, #effbfb 0%, #f7f9fb 180px, #f7f9fb 100%);
padding-bottom: 120px;
}
.scroll-content::-webkit-scrollbar { display: none; }
.page-body { padding: 16px; }
.section-title {
font-size: 18px;
font-weight: 700;
margin-bottom: 6px;
}
.section-desc {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 20px;
}
.type-card {
background: #fff;
border-radius: 20px;
padding: 20px 18px;
margin-bottom: 14px;
border: 2px solid transparent;
box-shadow: var(--shadow);
cursor: pointer;
transition: all 0.25s ease;
}
.type-card.active {
border-color: var(--primary);
background: var(--primary-light);
}
.type-card__header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 10px;
}
.type-card__icon {
width: 48px;
height: 48px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
flex-shrink: 0;
}
.type-card__icon--routine {
background: linear-gradient(135deg, #e6fffb 0%, #b5f5ec 100%);
}
.type-card__icon--gastro {
background: linear-gradient(135deg, #fff7e6 0%, #ffe7ba 100%);
}
.type-card__name {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
.type-card__desc {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.7;
}
.type-card__radio {
position: absolute;
right: 18px;
top: 50%;
transform: translateY(-50%);
}
.type-card__header { position: relative; }
.radio-dot {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid #d9d9d9;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.25s ease;
flex-shrink: 0;
margin-left: auto;
}
.type-card.active .radio-dot {
border-color: var(--primary);
background: var(--primary);
}
.radio-dot::after {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
background: #fff;
opacity: 0;
transition: opacity 0.25s ease;
}
.type-card.active .radio-dot::after { opacity: 1; }
.bottom-actions {
position: absolute;
left: 16px;
right: 16px;
bottom: 18px;
padding-bottom: 16px;
}
.bottom-actions__btn {
width: 100%;
height: 48px;
border-radius: 24px;
font-size: 15px;
font-weight: 600;
border: none;
cursor: pointer;
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%);
color: #fff;
box-shadow: 0 10px 20px rgba(19, 194, 194, 0.22);
}
</style>
</head>
<body>
<div class="phone-shell">
<div class="phone-status-bar">
<span class="time">12:00</span>
<div class="icons">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<div class="nav-bar">
<!-- [交互] 点击返回上一页 -->
<a class="nav-back" href="index.html">&#8249;</a>
<span class="nav-title">体检预约</span>
</div>
<div class="scroll-content">
<div class="page-body">
<div class="section-title">选择体检类型</div>
<div class="section-desc">请根据需求选择适合的体检项目</div>
<!-- [交互] 点击切换选中态,单选 -->
<div class="type-card active" onclick="selectType(this, 'routine')">
<div class="type-card__header">
<div class="type-card__icon type-card__icon--routine">🩺</div>
<div class="type-card__name">常规体检</div>
<div class="radio-dot"></div>
</div>
<div class="type-card__desc">基础健康综合筛查,覆盖核心指标</div>
</div>
<div class="type-card" onclick="selectType(this, 'gastro')">
<div class="type-card__header">
<div class="type-card__icon type-card__icon--gastro">🔬</div>
<div class="type-card__name">胃肠镜体检</div>
<div class="radio-dot"></div>
</div>
<div class="type-card__desc">胃肠内镜专项检查,直观诊断消化道病变</div>
</div>
</div>
</div>
<div class="bottom-actions">
<!-- [交互] 根据选中的体检类型跳转到对应流程 -->
<button class="bottom-actions__btn" onclick="goNext()">确定</button>
</div>
</div>
<script>
var selectedType = 'routine';
function selectType(el, type) {
document.querySelectorAll('.type-card').forEach(c => c.classList.remove('active'));
el.classList.add('active');
selectedType = type;
}
function goNext() {
if (selectedType === 'gastro') {
window.location.href = 'gastro-hospital.html?from=booking-type';
} else {
window.location.href = 'booking-hospital.html?from=booking-type';
}
}
</script>
</body>
</html>
+317
View File
@@ -0,0 +1,317 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>一键咨询</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: #13c2c2;
--primary-dark: #0f9b9b;
--primary-light: #e6fffb;
--text-primary: #1a1a1a;
--text-secondary: #666;
--text-muted: #8c8c8c;
--bg-page: #f0f2f5;
--bg-content: #f7f9fb;
--danger: #e84c4c;
}
body {
background: var(--bg-page);
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
color: var(--text-primary);
}
.phone-shell {
width: 390px;
min-height: 844px;
background: var(--bg-content);
border-radius: 44px;
box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
}
.phone-status-bar {
height: 44px;
background: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.phone-status-bar .time { font-size: 15px; font-weight: 700; }
.phone-status-bar .icons { display: flex; gap: 6px; align-items: center; }
.nav-bar {
height: 44px;
display: flex;
align-items: center;
padding: 0 16px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
position: relative;
}
.nav-back {
font-size: 18px;
color: var(--text-primary);
cursor: pointer;
line-height: 1;
text-decoration: none;
}
.nav-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 17px;
font-weight: 600;
color: var(--text-primary);
}
.scroll-content {
flex: 1;
overflow-y: auto;
background: #f5f5f5;
}
.scroll-content::-webkit-scrollbar { display: none; }
.page-body { padding: 0 0 28px; }
/* 大分类 */
.category {
background: #fff;
margin-bottom: 10px;
padding: 18px 16px 6px;
}
.category__title {
font-size: 17px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 16px;
}
/* 子分类卡片 */
.sub-section {
margin-bottom: 18px;
}
.sub-section__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.sub-section__name {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
}
.sub-section__bar {
width: 4px;
height: 18px;
border-radius: 2px;
background: var(--primary);
flex-shrink: 0;
}
.sub-section__link {
font-size: 13px;
color: var(--danger);
font-weight: 600;
text-decoration: none;
cursor: pointer;
white-space: nowrap;
}
/* 表格 */
.consult-table {
width: 100%;
border-collapse: collapse;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e8e8e8;
}
.consult-table thead th {
background: var(--primary);
color: #fff;
font-size: 13px;
font-weight: 600;
padding: 10px 8px;
text-align: center;
}
.consult-table tbody td {
font-size: 13px;
color: var(--text-primary);
padding: 11px 8px;
text-align: center;
border-bottom: 1px solid #f0f0f0;
background: #fff;
}
.consult-table tbody tr:last-child td {
border-bottom: none;
}
.consult-table thead th:first-child { width: 34%; }
.consult-table thead th:nth-child(2) { width: 22%; }
.consult-table thead th:nth-child(3) { width: 20%; }
.consult-table thead th:nth-child(4) { width: 24%; }
.is-abnormal { color: var(--danger); font-weight: 600; }
.consult-table tbody td:nth-child(2) { color: var(--danger); font-weight: 600; }
</style>
</head>
<body>
<div class="phone-shell">
<div class="phone-status-bar">
<span class="time">12:00</span>
<div class="icons">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<div class="nav-bar">
<a class="nav-back" href="index.html">&#8249;</a>
<span class="nav-title">一键咨询</span>
</div>
<div class="scroll-content">
<div class="page-body">
<!-- 化学检查 -->
<div class="category">
<div class="category__title">化学检查</div>
<!-- 血脂 -->
<div class="sub-section">
<div class="sub-section__header">
<div class="sub-section__name"><span class="sub-section__bar"></span>血脂</div>
<a class="sub-section__link">专家库一键咨询&gt;</a>
</div>
<table class="consult-table">
<thead><tr><th>名称</th><th>结果</th><th>单位</th><th>参考值</th></tr></thead>
<tbody>
<tr><td>高密度脂蛋白</td><td class="is-abnormal">0.90</td><td>mmol/L</td><td>1.04--1.66</td></tr>
</tbody>
</table>
</div>
<!-- 乙肝 -->
<div class="sub-section">
<div class="sub-section__header">
<div class="sub-section__name"><span class="sub-section__bar"></span>乙肝</div>
<a class="sub-section__link">专家库一键咨询&gt;</a>
</div>
<table class="consult-table">
<thead><tr><th>名称</th><th>结果</th><th>单位</th><th>参考值</th></tr></thead>
<tbody>
<tr><td>乙肝病毒表面抗体</td><td class="is-abnormal">弱阳性</td><td>/</td><td>阴性</td></tr>
</tbody>
</table>
</div>
<!-- 肝功 -->
<div class="sub-section">
<div class="sub-section__header">
<div class="sub-section__name"><span class="sub-section__bar"></span>肝功</div>
<a class="sub-section__link">专家库一键咨询&gt;</a>
</div>
<table class="consult-table">
<thead><tr><th>名称</th><th>结果</th><th>单位</th><th>参考值</th></tr></thead>
<tbody>
<tr><td>谷丙转氨酶</td><td class="is-abnormal">59</td><td>U/L</td><td>9--50</td></tr>
<tr><td>总胆红素</td><td class="is-abnormal">33.0</td><td>umol/L</td><td>3.42--23</td></tr>
<tr><td>直接胆红素</td><td class="is-abnormal">7.5</td><td>umol/L</td><td>0--6.84</td></tr>
<tr><td>间接胆红素</td><td class="is-abnormal">25.5</td><td>umol/L</td><td>3.4--16.1</td></tr>
</tbody>
</table>
</div>
<!-- 生化检查 -->
<div class="sub-section">
<div class="sub-section__header">
<div class="sub-section__name"><span class="sub-section__bar"></span>生化检查</div>
<a class="sub-section__link">专家库一键咨询&gt;</a>
</div>
<table class="consult-table">
<thead><tr><th>名称</th><th>结果</th><th>单位</th><th>参考值</th></tr></thead>
<tbody>
<tr><td>载脂蛋白-A1</td><td class="is-abnormal">0.88</td><td>g/L</td><td>1--1.6</td></tr>
</tbody>
</table>
</div>
</div>
<!-- 常规检查 -->
<div class="category">
<div class="category__title">常规检查</div>
<!-- 血常规 -->
<div class="sub-section">
<div class="sub-section__header">
<div class="sub-section__name"><span class="sub-section__bar"></span>血常规</div>
<a class="sub-section__link">专家库一键咨询&gt;</a>
</div>
<table class="consult-table">
<thead><tr><th>名称</th><th>结果</th><th>单位</th><th>参考值</th></tr></thead>
<tbody>
<tr><td>中性粒细胞比率</td><td class="is-abnormal">78.6</td><td>%</td><td>40--75</td></tr>
<tr><td>淋巴细胞比率</td><td class="is-abnormal">15.2</td><td>%</td><td>20--50</td></tr>
</tbody>
</table>
</div>
<!-- 尿常规 -->
<div class="sub-section">
<div class="sub-section__header">
<div class="sub-section__name"><span class="sub-section__bar"></span>尿常规</div>
<a class="sub-section__link">专家库一键咨询&gt;</a>
</div>
<table class="consult-table">
<thead><tr><th>名称</th><th>结果</th><th>单位</th><th>参考值</th></tr></thead>
<tbody>
<tr><td>尿蛋白</td><td class="is-abnormal">弱阳性</td><td>/</td><td>阴性</td></tr>
</tbody>
</table>
</div>
</div>
<!-- 影像检查 -->
<div class="category">
<div class="category__title">影像检查</div>
<div class="sub-section">
<div class="sub-section__header">
<div class="sub-section__name"><span class="sub-section__bar"></span>彩超</div>
<a class="sub-section__link">专家库一键咨询&gt;</a>
</div>
<table class="consult-table">
<thead><tr><th>名称</th><th>结果</th><th>单位</th><th>参考值</th></tr></thead>
<tbody>
<tr><td>肝胆脾胰双肾</td><td class="is-abnormal">轻度脂肪肝</td><td>/</td><td>未见异常</td></tr>
</tbody>
</table>
</div>
<div class="sub-section">
<div class="sub-section__header">
<div class="sub-section__name"><span class="sub-section__bar"></span>颈椎</div>
<a class="sub-section__link">专家库一键咨询&gt;</a>
</div>
<table class="consult-table">
<thead><tr><th>名称</th><th>结果</th><th>单位</th><th>参考值</th></tr></thead>
<tbody>
<tr><td>颈椎 CT</td><td class="is-abnormal">生理曲度变直</td><td>/</td><td>未见异常</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,312 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>胃肠镜预约 - 预约日期</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: #13c2c2;
--primary-dark: #0f9b9b;
--primary-light: #e6fffb;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--text-muted: #8c8c8c;
--border: #e8f3f3;
--bg-page: #f0f2f5;
--bg-content: #f7f9fb;
--shadow: 0 10px 30px rgba(19, 194, 194, 0.08);
}
body {
background: var(--bg-page); display: flex; justify-content: center;
align-items: flex-start; min-height: 100vh; padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
color: var(--text-primary);
}
.phone-shell {
width: 390px; min-height: 844px; background: var(--bg-content);
border-radius: 44px; box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0;
overflow: hidden; position: relative; display: flex; flex-direction: column;
}
.phone-status-bar {
height: 44px; background: #fff; display: flex; align-items: center;
justify-content: space-between; padding: 0 24px; font-size: 12px; font-weight: 600; flex-shrink: 0;
}
.phone-status-bar .time { font-size: 15px; font-weight: 700; }
.phone-status-bar .icons { display: flex; gap: 6px; align-items: center; }
.nav-bar {
height: 44px; display: flex; align-items: center; padding: 0 16px;
background: #fff; border-bottom: 1px solid #f0f0f0; flex-shrink: 0; position: relative;
}
.nav-back { font-size: 18px; color: var(--text-primary); cursor: pointer; line-height: 1; text-decoration: none; }
.nav-title { position: absolute; left: 50%; transform: translateX(-50%); font-size: 17px; font-weight: 600; color: var(--text-primary); }
.scroll-content { flex: 1; overflow-y: auto; background: linear-gradient(180deg, #effbfb 0%, #f7f9fb 180px, #f7f9fb 100%); padding-bottom: 120px; }
.scroll-content::-webkit-scrollbar { display: none; }
.page-body { padding: 16px; }
.stepper { display: flex; align-items: center; justify-content: center; margin-bottom: 18px; padding: 16px 0; }
.stepper__item { display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; text-decoration: none; }
.stepper__item--future { cursor: default; pointer-events: none; opacity: 0.45; }
.stepper__dot {
width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700; background: #e8e8e8; color: #bfbfbf; transition: all 0.25s;
}
.stepper__item--done .stepper__dot { background: var(--primary); color: #fff; }
.stepper__item--active { cursor: default; pointer-events: none; }
.stepper__item--active .stepper__dot {
width: 34px; height: 34px; font-size: 14px;
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%); color: #fff;
box-shadow: 0 0 0 6px rgba(19,194,194,0.2);
}
.stepper__label { font-size: 12px; color: var(--text-muted); white-space: nowrap; transition: all 0.2s; }
.stepper__item--done .stepper__label { color: var(--primary-dark); }
.stepper__item--active .stepper__label {
color: var(--primary-dark); font-weight: 700; font-size: 13px;
background: var(--primary-light); padding: 2px 10px; border-radius: 10px;
}
.stepper__item--done:hover .stepper__dot { transform: scale(1.12); box-shadow: 0 0 0 4px rgba(19,194,194,0.15); }
.stepper__item--done:hover .stepper__label { text-decoration: underline; text-underline-offset: 3px; }
.stepper__line { width: 44px; height: 3px; background: #e8e8e8; margin: 0 8px; margin-bottom: 22px; border-radius: 2px; }
.stepper__line--done { background: var(--primary); }
.section-title { font-size: 16px; font-weight: 700; margin-bottom: 14px; margin-top: 6px; }
.summary-card {
background: #fff; border-radius: 20px; padding: 18px 16px; margin-bottom: 14px;
box-shadow: var(--shadow); border: 1px solid rgba(19,194,194,0.06);
}
.summary-list { display: flex; flex-direction: column; gap: 14px; }
.summary-item { display: flex; justify-content: space-between; gap: 12px; }
.summary-item__label { font-size: 13px; color: var(--text-muted); flex-shrink: 0; }
.summary-item__value { font-size: 14px; font-weight: 600; text-align: right; line-height: 1.6; }
.date-card {
background: #fff; border-radius: 20px; padding: 18px 16px;
box-shadow: var(--shadow); border: 1px solid rgba(19,194,194,0.06);
}
.calendar-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; }
.calendar-header__month { font-size: 17px; font-weight: 700; color: var(--text-primary); }
.calendar-header__nav { display: flex; gap: 8px; }
.calendar-header__btn {
width: 32px; height: 32px; border-radius: 10px; border: 1px solid #e8e8e8;
background: #fff; display: flex; align-items: center; justify-content: center;
cursor: pointer; font-size: 14px; color: var(--text-secondary); transition: all 0.2s;
}
.calendar-header__btn:hover { border-color: var(--primary); color: var(--primary); }
.calendar-header__btn:disabled { opacity: 0.3; pointer-events: none; }
.calendar-weekdays { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; margin-bottom: 8px; }
.calendar-weekday { text-align: center; font-size: 12px; font-weight: 600; color: var(--text-muted); padding: 6px 0; }
.calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
.calendar-cell {
display: flex; flex-direction: column; align-items: center; justify-content: center;
height: 52px; border-radius: 12px; cursor: pointer; transition: all 0.2s;
border: 2px solid transparent; background: #f9fbfb; position: relative;
}
.calendar-cell:hover { border-color: var(--primary); }
.calendar-cell.selected { background: var(--primary); border-color: var(--primary); }
.calendar-cell.selected .calendar-cell__day,
.calendar-cell.selected .calendar-cell__slots { color: #fff; }
.calendar-cell.disabled { opacity: 0.3; pointer-events: none; background: #f5f5f5; }
.calendar-cell.empty { background: transparent; pointer-events: none; }
.calendar-cell__day { font-size: 15px; font-weight: 700; color: var(--text-primary); line-height: 1; }
.calendar-cell__slots { font-size: 10px; color: var(--primary-dark); font-weight: 600; margin-top: 2px; }
.calendar-cell__full { font-size: 10px; color: var(--text-muted); font-weight: 600; margin-top: 2px; }
.calendar-cell.today .calendar-cell__day { color: var(--primary-dark); }
.calendar-cell.today { border-color: rgba(19,194,194,0.3); }
.bottom-actions {
position: absolute; left: 16px; right: 16px; bottom: 18px;
display: flex; gap: 12px; padding-bottom: 16px;
}
.bottom-actions__btn { flex: 1; height: 48px; border-radius: 24px; font-size: 15px; font-weight: 600; border: none; cursor: pointer; }
.bottom-actions__btn--ghost { background: #fff; color: var(--primary-dark); border: 2px solid #d0eded; }
.bottom-actions__btn--primary {
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%);
color: #fff; box-shadow: 0 10px 20px rgba(19,194,194,0.22);
}
.modal-overlay {
position: absolute; inset: 0; background: rgba(0,0,0,0.42); z-index: 120;
display: flex; align-items: center; justify-content: center; padding: 24px;
}
.modal-overlay.hidden { display: none; }
.confirm-modal { width: 100%; background: #fff; border-radius: 24px; padding: 22px 20px 18px; box-shadow: 0 20px 50px rgba(0,0,0,0.16); }
.confirm-modal__title { font-size: 18px; font-weight: 700; text-align: center; margin-bottom: 8px; }
.confirm-modal__desc { font-size: 13px; color: var(--text-muted); text-align: center; line-height: 1.6; margin-bottom: 18px; }
.confirm-modal__info { background: #f7f9fb; border-radius: 16px; padding: 14px 16px; margin-bottom: 18px; }
.confirm-modal__row { display: flex; justify-content: space-between; align-items: center; font-size: 14px; color: var(--text-secondary); margin-bottom: 12px; }
.confirm-modal__row:last-child { margin-bottom: 0; }
.confirm-modal__value { font-size: 15px; font-weight: 700; color: var(--text-primary); }
.confirm-modal__value--highlight { color: var(--primary-dark); }
.confirm-modal__actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.confirm-modal__btn { height: 44px; border-radius: 22px; font-size: 15px; font-weight: 600; border: none; cursor: pointer; }
.confirm-modal__btn--ghost { background: #f5f5f5; color: var(--text-secondary); }
.confirm-modal__btn--primary { background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%); color: #fff; }
.toast {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%) scale(0.9);
background: rgba(0,0,0,0.75); color: #fff; padding: 14px 22px; border-radius: 12px;
font-size: 13px; line-height: 1.6; text-align: center; z-index: 200;
opacity: 0; pointer-events: none; transition: all 0.25s ease; max-width: 280px;
}
.toast.show { opacity: 1; transform: translate(-50%, -50%) scale(1); }
</style>
</head>
<body>
<div class="phone-shell">
<div class="phone-status-bar">
<span class="time">12:00</span>
<div class="icons">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<div class="nav-bar">
<a class="nav-back" href="gastro-questionnaire.html">&#8249;</a>
<span class="nav-title">胃肠镜预约</span>
</div>
<div class="scroll-content">
<div class="page-body">
<div class="stepper">
<a class="stepper__item stepper__item--done" href="gastro-hospital.html"><div class="stepper__dot">&#10003;</div><div class="stepper__label">选择医院</div></a>
<div class="stepper__line stepper__line--done"></div>
<a class="stepper__item stepper__item--done" href="gastro-questionnaire.html"><div class="stepper__dot">&#10003;</div><div class="stepper__label">填问卷</div></a>
<div class="stepper__line stepper__line--done"></div>
<div class="stepper__item stepper__item--active"><div class="stepper__dot">3</div><div class="stepper__label">预约日期</div></div>
</div>
<div class="summary-card">
<div class="section-title">预约信息</div>
<div class="summary-list">
<div class="summary-item"><div class="summary-item__label">体检类型</div><div class="summary-item__value">胃肠镜体检</div></div>
<div class="summary-item"><div class="summary-item__label">体检医院</div><div class="summary-item__value">宁夏宝石花医院</div></div>
<div class="summary-item"><div class="summary-item__label">问卷状态</div><div class="summary-item__value" style="color:var(--primary-dark)">已完成</div></div>
</div>
</div>
<div class="date-card">
<div class="calendar-header">
<span class="calendar-header__month" id="calendarMonth"></span>
<div class="calendar-header__nav">
<button class="calendar-header__btn" id="prevMonth">&#8249;</button>
<button class="calendar-header__btn" id="nextMonth">&#8250;</button>
</div>
</div>
<div class="calendar-weekdays">
<span class="calendar-weekday"></span><span class="calendar-weekday"></span>
<span class="calendar-weekday"></span><span class="calendar-weekday"></span>
<span class="calendar-weekday"></span><span class="calendar-weekday"></span>
<span class="calendar-weekday"></span>
</div>
<div class="calendar-grid" id="calendarGrid"></div>
</div>
</div>
</div>
<div class="bottom-actions">
<button class="bottom-actions__btn bottom-actions__btn--ghost" onclick="location.href='gastro-questionnaire.html'">上一步</button>
<button class="bottom-actions__btn bottom-actions__btn--primary" onclick="openConfirm()">确定预约</button>
</div>
<div class="modal-overlay hidden" id="confirmModal">
<div class="confirm-modal">
<div class="confirm-modal__title">确认预约信息</div>
<div class="confirm-modal__desc">请确认以下胃肠镜预约信息无误</div>
<div class="confirm-modal__info">
<div class="confirm-modal__row"><span>体检类型</span><span class="confirm-modal__value">胃肠镜体检</span></div>
<div class="confirm-modal__row"><span>体检医院</span><span class="confirm-modal__value">宁夏宝石花医院</span></div>
<div class="confirm-modal__row"><span>预约日期</span><span class="confirm-modal__value confirm-modal__value--highlight" id="confirmDate">--</span></div>
</div>
<div class="confirm-modal__actions">
<button class="confirm-modal__btn confirm-modal__btn--ghost" onclick="closeConfirm()">取消</button>
<button class="confirm-modal__btn confirm-modal__btn--primary" onclick="showSuccessToast()">确认预约</button>
</div>
</div>
</div>
<div class="toast" id="successToast">预约成功!请按时前往医院进行胃肠镜检查</div>
</div>
<script>
var today = new Date(2026, 2, 16);
var currentYear = today.getFullYear();
var currentMonth = today.getMonth();
var selectedDate = null;
var grid = document.getElementById('calendarGrid');
var monthLabel = document.getElementById('calendarMonth');
var prevBtn = document.getElementById('prevMonth');
var nextBtn = document.getElementById('nextMonth');
var toastTimer = null;
function getSlots(year, month, day) {
var d = new Date(year, month, day);
var dow = d.getDay();
if (d < new Date(today.getFullYear(), today.getMonth(), today.getDate())) return -1;
if (dow === 0 || dow === 6) return 0;
var seed = (day * 7 + month * 13) % 20;
if (seed < 3) return 0;
return seed + 2;
}
function renderCalendar() {
grid.innerHTML = '';
monthLabel.textContent = currentYear + '年' + (currentMonth + 1) + '月';
prevBtn.disabled = (currentYear === today.getFullYear() && currentMonth <= today.getMonth());
var firstDay = new Date(currentYear, currentMonth, 1);
var startDow = firstDay.getDay();
startDow = startDow === 0 ? 6 : startDow - 1;
var daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate();
for (var i = 0; i < startDow; i++) {
var empty = document.createElement('div');
empty.className = 'calendar-cell empty';
grid.appendChild(empty);
}
for (var day = 1; day <= daysInMonth; day++) {
(function(d) {
var cell = document.createElement('div');
var slots = getSlots(currentYear, currentMonth, d);
var isToday = (currentYear === today.getFullYear() && currentMonth === today.getMonth() && d === today.getDate());
var isDisabled = slots <= 0;
var isFull = slots === 0;
var isPast = slots === -1;
var cls = 'calendar-cell';
if (isToday) cls += ' today';
if (isDisabled) cls += ' disabled';
if (selectedDate && selectedDate.y === currentYear && selectedDate.m === currentMonth && selectedDate.d === d) cls += ' selected';
cell.className = cls;
var slotsHtml = '';
if (isPast) slotsHtml = '';
else if (isFull) slotsHtml = '<span class="calendar-cell__full">不可约</span>';
else if (slots > 0) slotsHtml = '<span class="calendar-cell__slots">余' + slots + '</span>';
cell.innerHTML = '<span class="calendar-cell__day">' + d + '</span>' + slotsHtml;
if (!isDisabled) {
cell.addEventListener('click', function() {
selectedDate = { y: currentYear, m: currentMonth, d: d };
renderCalendar();
});
}
grid.appendChild(cell);
})(day);
}
}
prevBtn.addEventListener('click', function() {
currentMonth--;
if (currentMonth < 0) { currentMonth = 11; currentYear--; }
renderCalendar();
});
nextBtn.addEventListener('click', function() {
currentMonth++;
if (currentMonth > 11) { currentMonth = 0; currentYear++; }
renderCalendar();
});
selectedDate = { y: today.getFullYear(), m: today.getMonth(), d: today.getDate() };
renderCalendar();
function openConfirm() {
if (!selectedDate) return;
document.getElementById('confirmDate').textContent = selectedDate.y + '年' + (selectedDate.m + 1) + '月' + selectedDate.d + '日';
document.getElementById('confirmModal').classList.remove('hidden');
}
function closeConfirm() { document.getElementById('confirmModal').classList.add('hidden'); }
function showSuccessToast() {
closeConfirm();
var toast = document.getElementById('successToast');
toast.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(function() { toast.classList.remove('show'); }, 2200);
}
</script>
</body>
</html>
@@ -0,0 +1,224 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>胃肠镜预约</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: #13c2c2;
--primary-dark: #0f9b9b;
--primary-light: #e6fffb;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--text-muted: #8c8c8c;
--border: #e8f3f3;
--bg-page: #f0f2f5;
--bg-content: #f7f9fb;
--shadow: 0 10px 30px rgba(19, 194, 194, 0.08);
}
body {
background: var(--bg-page);
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
color: var(--text-primary);
}
.phone-shell {
width: 390px; min-height: 844px; background: var(--bg-content);
border-radius: 44px; box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0;
overflow: hidden; position: relative; display: flex; flex-direction: column;
}
.phone-status-bar {
height: 44px; background: #fff; display: flex; align-items: center;
justify-content: space-between; padding: 0 24px; font-size: 12px; font-weight: 600; flex-shrink: 0;
}
.phone-status-bar .time { font-size: 15px; font-weight: 700; }
.phone-status-bar .icons { display: flex; gap: 6px; align-items: center; }
.nav-bar {
height: 44px; display: flex; align-items: center; padding: 0 16px;
background: #fff; border-bottom: 1px solid #f0f0f0; flex-shrink: 0; position: relative;
}
.nav-back { font-size: 18px; color: var(--text-primary); cursor: pointer; line-height: 1; text-decoration: none; }
.nav-title { position: absolute; left: 50%; transform: translateX(-50%); font-size: 17px; font-weight: 600; color: var(--text-primary); }
.nav-right {
position: absolute; right: 16px; font-size: 13px; color: var(--primary);
font-weight: 600; cursor: pointer; display: flex; align-items: center; gap: 4px;
}
.scroll-content { flex: 1; overflow-y: auto; background: linear-gradient(180deg, #effbfb 0%, #f7f9fb 180px, #f7f9fb 100%); padding-bottom: 120px; }
.scroll-content::-webkit-scrollbar { display: none; }
.page-body { padding: 16px; }
.stepper { display: flex; align-items: center; justify-content: center; margin-bottom: 18px; padding: 16px 0; }
.stepper__item { display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; text-decoration: none; }
.stepper__item--future { cursor: default; pointer-events: none; opacity: 0.45; }
.stepper__dot {
width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700; background: #e8e8e8; color: #bfbfbf; transition: all 0.25s;
}
.stepper__item--done .stepper__dot { background: var(--primary); color: #fff; }
.stepper__item--active { cursor: default; pointer-events: none; }
.stepper__item--active .stepper__dot {
width: 34px; height: 34px; font-size: 14px;
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%); color: #fff;
box-shadow: 0 0 0 6px rgba(19,194,194,0.2);
}
.stepper__label { font-size: 12px; color: var(--text-muted); white-space: nowrap; transition: all 0.2s; }
.stepper__item--done .stepper__label { color: var(--primary-dark); }
.stepper__item--active .stepper__label {
color: var(--primary-dark); font-weight: 700; font-size: 13px;
background: var(--primary-light); padding: 2px 10px; border-radius: 10px;
}
.stepper__line { width: 44px; height: 3px; background: #e8e8e8; margin: 0 8px; margin-bottom: 22px; border-radius: 2px; }
.stepper__line--done { background: var(--primary); }
.section-title { font-size: 16px; font-weight: 700; margin-bottom: 14px; margin-top: 6px; }
.hospital-card {
background: #fff; border-radius: 18px; padding: 16px; margin-bottom: 12px;
border: 2px solid transparent; box-shadow: var(--shadow); cursor: pointer;
display: flex; align-items: center; justify-content: space-between; transition: all 0.25s ease;
}
.hospital-card.active { border-color: var(--primary); background: var(--primary-light); }
.hospital-card__info { display: flex; align-items: center; gap: 12px; }
.hospital-card__icon {
width: 42px; height: 42px; border-radius: 14px;
background: linear-gradient(135deg, #e6fffb 0%, #b5f5ec 100%);
display: flex; align-items: center; justify-content: center; font-size: 20px; flex-shrink: 0;
}
.hospital-card__name { font-size: 15px; font-weight: 600; color: var(--text-primary); margin-bottom: 2px; }
.hospital-card__addr { font-size: 12px; color: var(--text-muted); }
.radio-dot {
width: 22px; height: 22px; border-radius: 50%; border: 2px solid #d9d9d9;
display: flex; align-items: center; justify-content: center; transition: all 0.25s ease; flex-shrink: 0;
}
.hospital-card.active .radio-dot { border-color: var(--primary); background: var(--primary); }
.radio-dot::after { content: ''; width: 8px; height: 8px; border-radius: 50%; background: #fff; opacity: 0; transition: opacity 0.25s ease; }
.hospital-card.active .radio-dot::after { opacity: 1; }
.bottom-actions { position: absolute; left: 16px; right: 16px; bottom: 18px; padding-bottom: 16px; }
.bottom-actions__btn {
width: 100%; height: 48px; border-radius: 24px; font-size: 15px; font-weight: 600;
border: none; cursor: pointer; background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%);
color: #fff; box-shadow: 0 10px 20px rgba(19, 194, 194, 0.22);
}
.modal-overlay {
position: absolute; inset: 0; background: rgba(0,0,0,0.45); z-index: 100;
display: flex; align-items: center; justify-content: center;
}
.modal-overlay.hidden { display: none; }
.modal-card { width: 340px; background: #fff; border-radius: 24px; padding: 24px 20px; box-shadow: 0 20px 60px rgba(0,0,0,0.18); }
.modal-card__title { font-size: 18px; font-weight: 700; text-align: center; margin-bottom: 6px; }
.modal-card__sub { font-size: 13px; color: var(--text-muted); text-align: center; margin-bottom: 20px; }
.modal-field { display: flex; justify-content: space-between; align-items: center; padding: 14px 0; border-bottom: 1px solid #f0f0f0; }
.modal-field:last-of-type { border-bottom: none; }
.modal-field__label { font-size: 14px; color: var(--text-muted); }
.modal-field__value { font-size: 14px; color: var(--text-primary); font-weight: 600; }
.marriage-toggle { display: inline-flex; background: #f5f5f5; border-radius: 20px; overflow: hidden; }
.marriage-toggle__btn {
padding: 6px 16px; font-size: 13px; font-weight: 600; border: none;
background: transparent; color: var(--text-muted); cursor: pointer; border-radius: 20px; transition: all 0.2s ease;
}
.marriage-toggle__btn.active { background: var(--primary); color: #fff; }
.modal-confirm-btn {
margin-top: 20px; width: 100%; height: 44px; border-radius: 22px; font-size: 15px; font-weight: 600;
border: none; cursor: pointer; background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%);
color: #fff; box-shadow: 0 8px 20px rgba(19, 194, 194, 0.2);
}
</style>
</head>
<body>
<div class="phone-shell">
<div class="phone-status-bar">
<span class="time">12:00</span>
<div class="icons">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<div class="nav-bar">
<a class="nav-back" href="booking-type.html">&#8249;</a>
<span class="nav-title">胃肠镜预约</span>
<span class="nav-right" onclick="openModal()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
个人信息
</span>
</div>
<div class="scroll-content">
<div class="page-body">
<div class="stepper">
<a class="stepper__item stepper__item--active" href="gastro-hospital.html"><div class="stepper__dot">1</div><div class="stepper__label">选择医院</div></a>
<div class="stepper__line"></div>
<div class="stepper__item stepper__item--future"><div class="stepper__dot">2</div><div class="stepper__label">填问卷</div></div>
<div class="stepper__line"></div>
<div class="stepper__item stepper__item--future"><div class="stepper__dot">3</div><div class="stepper__label">预约日期</div></div>
</div>
<div class="section-title">选择医院</div>
<div class="hospital-card active" onclick="selectHospital(this)">
<div class="hospital-card__info">
<div class="hospital-card__icon">🏥</div>
<div><div class="hospital-card__name">宁夏宝石花医院</div><div class="hospital-card__addr">宁夏银川市</div></div>
</div>
<div class="radio-dot"></div>
</div>
<div class="hospital-card" onclick="selectHospital(this)">
<div class="hospital-card__info">
<div class="hospital-card__icon">🏥</div>
<div><div class="hospital-card__name">长庆星龙园医院</div><div class="hospital-card__addr">陕西西安市</div></div>
</div>
<div class="radio-dot"></div>
</div>
<div class="hospital-card" onclick="selectHospital(this)">
<div class="hospital-card__info">
<div class="hospital-card__icon">🏥</div>
<div><div class="hospital-card__name">西安长庆医院</div><div class="hospital-card__addr">陕西西安市</div></div>
</div>
<div class="radio-dot"></div>
</div>
</div>
</div>
<div class="bottom-actions">
<button class="bottom-actions__btn" onclick="location.href='gastro-questionnaire.html'">下一步</button>
</div>
<div class="modal-overlay hidden" id="personModal">
<div class="modal-card">
<div class="modal-card__title">确认个人信息</div>
<div class="modal-card__sub">请核对以下信息,确认无误后继续预约</div>
<div class="modal-field"><span class="modal-field__label">姓名</span><span class="modal-field__value">刘韵琴</span></div>
<div class="modal-field"><span class="modal-field__label">单位</span><span class="modal-field__value">长庆油田第一采油厂</span></div>
<div class="modal-field"><span class="modal-field__label">身份证号</span><span class="modal-field__value">6101021994********</span></div>
<div class="modal-field"><span class="modal-field__label">性别</span><span class="modal-field__value"></span></div>
<div class="modal-field">
<span class="modal-field__label">婚姻状况</span>
<div class="marriage-toggle">
<button class="marriage-toggle__btn active" onclick="toggleMarriage(this)">已婚</button>
<button class="marriage-toggle__btn" onclick="toggleMarriage(this)">未婚</button>
</div>
</div>
<button class="modal-confirm-btn" onclick="closeModal()">确认</button>
</div>
</div>
</div>
<script>
if (new URLSearchParams(window.location.search).get('from') === 'booking-type') {
document.getElementById('personModal').classList.remove('hidden');
}
function selectHospital(el) {
document.querySelectorAll('.hospital-card').forEach(c => c.classList.remove('active'));
el.classList.add('active');
}
function openModal() { document.getElementById('personModal').classList.remove('hidden'); }
function closeModal() { document.getElementById('personModal').classList.add('hidden'); }
function toggleMarriage(btn) {
document.querySelectorAll('.marriage-toggle__btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
}
</script>
</body>
</html>
@@ -0,0 +1,200 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>胃肠镜预约 - 填问卷</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: #13c2c2;
--primary-dark: #0f9b9b;
--primary-light: #e6fffb;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--text-muted: #8c8c8c;
--bg-page: #f0f2f5;
--bg-content: #f7f9fb;
--shadow: 0 10px 30px rgba(19, 194, 194, 0.08);
}
body {
background: var(--bg-page); display: flex; justify-content: center;
align-items: flex-start; min-height: 100vh; padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
color: var(--text-primary);
}
.phone-shell {
width: 390px; min-height: 844px; background: var(--bg-content);
border-radius: 44px; box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0;
overflow: hidden; position: relative; display: flex; flex-direction: column;
}
.phone-status-bar {
height: 44px; background: #fff; display: flex; align-items: center;
justify-content: space-between; padding: 0 24px; font-size: 12px; font-weight: 600; flex-shrink: 0;
}
.phone-status-bar .time { font-size: 15px; font-weight: 700; }
.phone-status-bar .icons { display: flex; gap: 6px; align-items: center; }
.nav-bar {
height: 44px; display: flex; align-items: center; padding: 0 16px;
background: #fff; border-bottom: 1px solid #f0f0f0; flex-shrink: 0; position: relative;
}
.nav-back { font-size: 18px; color: var(--text-primary); cursor: pointer; line-height: 1; text-decoration: none; }
.nav-title { position: absolute; left: 50%; transform: translateX(-50%); font-size: 17px; font-weight: 600; color: var(--text-primary); }
.scroll-content { flex: 1; overflow-y: auto; background: linear-gradient(180deg, #effbfb 0%, #f7f9fb 180px, #f7f9fb 100%); padding-bottom: 120px; }
.scroll-content::-webkit-scrollbar { display: none; }
.page-body { padding: 16px; }
.stepper { display: flex; align-items: center; justify-content: center; margin-bottom: 18px; padding: 16px 0; }
.stepper__item { display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; text-decoration: none; }
.stepper__item--future { cursor: default; pointer-events: none; opacity: 0.45; }
.stepper__dot {
width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700; background: #e8e8e8; color: #bfbfbf; transition: all 0.25s;
}
.stepper__item--done .stepper__dot { background: var(--primary); color: #fff; }
.stepper__item--active { cursor: default; pointer-events: none; }
.stepper__item--active .stepper__dot {
width: 34px; height: 34px; font-size: 14px;
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%); color: #fff;
box-shadow: 0 0 0 6px rgba(19,194,194,0.2);
}
.stepper__label { font-size: 12px; color: var(--text-muted); white-space: nowrap; transition: all 0.2s; }
.stepper__item--done .stepper__label { color: var(--primary-dark); }
.stepper__item--active .stepper__label {
color: var(--primary-dark); font-weight: 700; font-size: 13px;
background: var(--primary-light); padding: 2px 10px; border-radius: 10px;
}
.stepper__item--done:hover .stepper__dot { transform: scale(1.12); box-shadow: 0 0 0 4px rgba(19,194,194,0.15); }
.stepper__item--done:hover .stepper__label { text-decoration: underline; text-underline-offset: 3px; }
.stepper__line { width: 44px; height: 3px; background: #e8e8e8; margin: 0 8px; margin-bottom: 22px; border-radius: 2px; }
.stepper__line--done { background: var(--primary); }
.section-title { font-size: 16px; font-weight: 700; margin-bottom: 6px; }
.section-desc { font-size: 13px; color: var(--text-muted); margin-bottom: 20px; }
.q-card {
background: #fff; border-radius: 18px; padding: 18px; margin-bottom: 14px;
box-shadow: var(--shadow);
}
.q-card__index {
display: inline-flex; align-items: center; justify-content: center;
width: 22px; height: 22px; border-radius: 50%; background: var(--primary);
color: #fff; font-size: 11px; font-weight: 700; margin-right: 8px; flex-shrink: 0;
}
.q-card__label {
font-size: 15px; font-weight: 600; color: var(--text-primary);
display: flex; align-items: center; margin-bottom: 14px;
}
.q-options { display: flex; gap: 12px; }
.q-option {
flex: 1; height: 44px; border-radius: 22px; border: 2px solid #e8e8e8;
background: #fff; font-size: 14px; font-weight: 600; color: var(--text-secondary);
cursor: pointer; transition: all 0.2s ease; display: flex; align-items: center; justify-content: center;
}
.q-option.active { border-color: var(--primary); background: var(--primary-light); color: var(--primary-dark); }
.bottom-actions {
position: absolute; left: 16px; right: 16px; bottom: 18px; padding-bottom: 16px;
display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
}
.bottom-actions__btn {
height: 48px; border-radius: 24px; font-size: 15px; font-weight: 600; border: none; cursor: pointer;
}
.bottom-actions__btn--ghost {
background: #fff; color: var(--primary-dark); border: 2px solid #d0eded;
}
.bottom-actions__btn--primary {
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%);
color: #fff; box-shadow: 0 10px 20px rgba(19, 194, 194, 0.22);
}
</style>
</head>
<body>
<div class="phone-shell">
<div class="phone-status-bar">
<span class="time">12:00</span>
<div class="icons">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<div class="nav-bar">
<a class="nav-back" href="gastro-hospital.html">&#8249;</a>
<span class="nav-title">胃肠镜预约</span>
</div>
<div class="scroll-content">
<div class="page-body">
<div class="stepper">
<a class="stepper__item stepper__item--done" href="gastro-hospital.html"><div class="stepper__dot">&#10003;</div><div class="stepper__label">选择医院</div></a>
<div class="stepper__line stepper__line--done"></div>
<div class="stepper__item stepper__item--active"><div class="stepper__dot">2</div><div class="stepper__label">填问卷</div></div>
<div class="stepper__line"></div>
<div class="stepper__item stepper__item--future"><div class="stepper__dot">3</div><div class="stepper__label">预约日期</div></div>
</div>
<div class="section-title">胃肠镜检查问卷</div>
<div class="section-desc">请如实填写以下信息,以便医生评估检查方案</div>
<div class="q-card">
<div class="q-card__label"><span class="q-card__index">1</span>是否使用阿司匹林?</div>
<div class="q-options">
<button class="q-option" onclick="toggleOption(this)"></button>
<button class="q-option active" onclick="toggleOption(this)"></button>
</div>
</div>
<div class="q-card">
<div class="q-card__label"><span class="q-card__index">2</span>是否有心脏疾病?</div>
<div class="q-options">
<button class="q-option" onclick="toggleOption(this)"></button>
<button class="q-option active" onclick="toggleOption(this)"></button>
</div>
</div>
<div class="q-card">
<div class="q-card__label"><span class="q-card__index">3</span>是否有肺部疾病?</div>
<div class="q-options">
<button class="q-option" onclick="toggleOption(this)"></button>
<button class="q-option active" onclick="toggleOption(this)"></button>
</div>
</div>
<div class="q-card">
<div class="q-card__label"><span class="q-card__index">4</span>是否有哮喘?</div>
<div class="q-options">
<button class="q-option" onclick="toggleOption(this)"></button>
<button class="q-option active" onclick="toggleOption(this)"></button>
</div>
</div>
<div class="q-card">
<div class="q-card__label"><span class="q-card__index">5</span>胸部是否检查?</div>
<div class="q-options">
<button class="q-option active" onclick="toggleOption(this)"></button>
<button class="q-option" onclick="toggleOption(this)"></button>
</div>
</div>
<div class="q-card">
<div class="q-card__label"><span class="q-card__index">6</span>血液是否检查?</div>
<div class="q-options">
<button class="q-option active" onclick="toggleOption(this)"></button>
<button class="q-option" onclick="toggleOption(this)"></button>
</div>
</div>
</div>
</div>
<div class="bottom-actions">
<button class="bottom-actions__btn bottom-actions__btn--ghost" onclick="location.href='gastro-hospital.html'">上一步</button>
<button class="bottom-actions__btn bottom-actions__btn--primary" onclick="location.href='gastro-date.html'">下一步</button>
</div>
</div>
<script>
function toggleOption(btn) {
var group = btn.parentElement;
group.querySelectorAll('.q-option').forEach(function(b) { b.classList.remove('active'); });
btn.classList.add('active');
}
</script>
</body>
</html>
@@ -0,0 +1,253 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>到院体检</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: #13c2c2;
--primary-dark: #0f9b9b;
--primary-light: #e6fffb;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--text-muted: #8c8c8c;
--bg-page: #f0f2f5;
--bg-content: #f7f9fb;
--shadow: 0 10px 26px rgba(19, 194, 194, 0.08);
}
body {
background: var(--bg-page);
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
color: var(--text-primary);
}
.phone-shell {
width: 390px;
min-height: 844px;
background: var(--bg-content);
border-radius: 44px;
box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
}
.phone-status-bar {
height: 44px;
background: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.phone-status-bar .time { font-size: 15px; font-weight: 700; }
.phone-status-bar .icons { display: flex; gap: 6px; align-items: center; }
.nav-bar {
height: 44px;
display: flex;
align-items: center;
padding: 0 16px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
position: relative;
}
.nav-back {
font-size: 18px;
color: var(--text-primary);
cursor: pointer;
line-height: 1;
text-decoration: none;
}
.nav-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 17px;
font-weight: 600;
color: var(--text-primary);
}
.scroll-content {
flex: 1;
overflow-y: auto;
background: linear-gradient(180deg, #effbfb 0%, #f7f9fb 160px, #f7f9fb 100%);
padding: 16px;
padding-bottom: 110px;
}
.scroll-content::-webkit-scrollbar { display: none; }
.hero-card {
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%);
border-radius: 24px;
padding: 22px 18px;
color: #fff;
box-shadow: 0 12px 28px rgba(19, 194, 194, 0.22);
}
.hero-card__badge {
display: inline-flex;
align-items: center;
padding: 6px 12px;
border-radius: 999px;
background: rgba(255,255,255,0.18);
font-size: 12px;
font-weight: 600;
margin-bottom: 14px;
}
.hero-card__title {
font-size: 24px;
font-weight: 700;
margin-bottom: 8px;
}
.hero-card__desc {
font-size: 13px;
line-height: 1.7;
opacity: 0.92;
}
.detail-card,
.notice-card {
margin-top: 14px;
background: #fff;
border-radius: 20px;
padding: 18px 16px;
box-shadow: var(--shadow);
border: 1px solid rgba(19, 194, 194, 0.06);
}
.section-title {
font-size: 16px;
font-weight: 700;
margin-bottom: 14px;
}
.detail-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.detail-item {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.detail-item__label {
font-size: 13px;
color: var(--text-muted);
flex-shrink: 0;
}
.detail-item__value {
font-size: 14px;
color: var(--text-primary);
font-weight: 600;
text-align: right;
line-height: 1.6;
}
.notice-card__text {
font-size: 14px;
line-height: 1.9;
color: var(--text-secondary);
}
.route-card {
margin-top: 14px;
padding: 16px;
border-radius: 20px;
background: linear-gradient(135deg, #ffffff 0%, #f5fffe 100%);
border: 1px solid #e3f7f5;
box-shadow: var(--shadow);
}
.route-card__title {
font-size: 15px;
font-weight: 700;
margin-bottom: 8px;
}
.route-card__desc {
font-size: 13px;
line-height: 1.7;
color: var(--text-secondary);
}
.bottom-action {
position: absolute;
left: 16px;
right: 16px;
bottom: 18px;
padding-bottom: 16px;
}
.bottom-action__btn {
width: 100%;
height: 48px;
border: none;
border-radius: 24px;
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%);
color: #fff;
font-size: 15px;
font-weight: 600;
box-shadow: 0 10px 20px rgba(19, 194, 194, 0.22);
cursor: pointer;
}
</style>
</head>
<body>
<div class="phone-shell">
<div class="phone-status-bar">
<span class="time">12:00</span>
<div class="icons">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<div class="nav-bar">
<!-- [交互] 点击返回至: my-appointments.html -->
<a class="nav-back" href="my-appointments.html"></a>
<span class="nav-title">到院体检</span>
</div>
<div class="scroll-content">
<div class="hero-card">
<div class="hero-card__badge">到院签到提醒</div>
<div class="hero-card__title">常规体检</div>
<div class="hero-card__desc">请根据预约信息前往对应医院,完成签到后按体检单顺序进行检查,异常情况可联系导检台。</div>
</div>
<div class="detail-card">
<div class="section-title">预约详情</div>
<div class="detail-list">
<div class="detail-item">
<div class="detail-item__label">体检医院</div>
<div class="detail-item__value">宁夏宝石花医院</div>
</div>
<div class="detail-item">
<div class="detail-item__label">预约日期</div>
<div class="detail-item__value">2026-03-23</div>
</div>
<div class="detail-item">
<div class="detail-item__label">体检地址</div>
<div class="detail-item__value">凤城五路与开元路十字东南角</div>
</div>
</div>
</div>
<div class="notice-card">
<div class="section-title">体检须知</div>
<div class="notice-card__text">体检前一天请清淡饮食,勿饮酒,勿劳累;体检当天请空腹,禁食。</div>
</div>
<div class="route-card">
<div class="route-card__title">到院建议</div>
<div class="route-card__desc">建议您提前 15 分钟到院,便于完成签到、核验身份与领取体检单,避免高峰时段排队等待。</div>
</div>
</div>
<div class="bottom-action">
<!-- [交互] 点击跳转至: my-appointments.html -->
<button class="bottom-action__btn" onclick="window.location.href='my-appointments.html'">返回我的预约</button>
</div>
</div>
</body>
</html>
@@ -0,0 +1,280 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>健康体检</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #f0f2f5;
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
}
.phone-shell {
width: 390px;
min-height: 844px;
background: #f7f9fb;
border-radius: 44px;
box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
}
.phone-status-bar {
height: 44px;
background: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.phone-status-bar .time { font-size: 15px; font-weight: 700; }
.phone-status-bar .icons { display: flex; gap: 6px; align-items: center; }
.nav-bar {
height: 44px;
display: flex;
align-items: center;
padding: 0 16px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
position: relative;
}
.nav-back {
font-size: 18px;
color: #1a1a1a;
cursor: pointer;
line-height: 1;
text-decoration: none;
}
.nav-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 17px;
font-weight: 600;
color: #1a1a1a;
}
.scroll-content {
flex: 1;
overflow-y: auto;
background: linear-gradient(180deg, #f3fbfb 0%, #f7f9fb 180px, #f7f9fb 100%);
}
.scroll-content::-webkit-scrollbar { display: none; }
.page-body { padding: 16px; }
.hero-card {
background: linear-gradient(135deg, #ffffff 0%, #f3fffe 100%);
border: 1px solid #d9f7f3;
border-radius: 20px;
padding: 18px 16px;
box-shadow: 0 6px 18px rgba(20, 180, 176, 0.08);
position: relative;
overflow: hidden;
}
.hero-card::after {
content: '';
position: absolute;
right: -36px;
top: -36px;
width: 120px;
height: 120px;
border-radius: 50%;
background: rgba(19, 194, 194, 0.08);
}
.hero-card__badge {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 10px;
border-radius: 14px;
background: rgba(250, 173, 20, 0.12);
color: #d48806;
font-size: 12px;
font-weight: 600;
margin-bottom: 12px;
position: relative;
z-index: 1;
}
.hero-card__title {
font-size: 22px;
line-height: 1.2;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 10px;
position: relative;
z-index: 1;
}
.hero-card__desc {
font-size: 14px;
line-height: 1.7;
color: #666;
position: relative;
z-index: 1;
}
.pending-card {
margin-top: 14px;
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%);
border-radius: 18px;
padding: 16px;
color: #fff;
box-shadow: 0 10px 24px rgba(19, 194, 194, 0.22);
}
.pending-card__label { font-size: 13px; opacity: 0.9; margin-bottom: 8px; }
.pending-card__value { font-size: 28px; font-weight: 800; line-height: 1; }
.pending-card__meta { margin-top: 10px; font-size: 12px; opacity: 0.86; }
.section-title { font-size: 16px; font-weight: 700; color: #1a1a1a; margin: 22px 2px 12px; }
.feature-list { display: flex; flex-direction: column; gap: 12px; }
.feature-card {
background: #fff; border-radius: 18px; padding: 16px; display: flex; align-items: center; gap: 14px;
box-shadow: 0 4px 14px rgba(0,0,0,0.05); border: 1px solid #f0f0f0; cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.feature-card:active { transform: scale(0.985); }
.feature-card__icon {
width: 52px; height: 52px; border-radius: 16px; display: flex; align-items: center;
justify-content: center; flex-shrink: 0;
}
.feature-card__content { flex: 1; min-width: 0; }
.feature-card__title { font-size: 16px; font-weight: 700; color: #1a1a1a; margin-bottom: 4px; }
.feature-card__desc { font-size: 12px; line-height: 1.6; color: #8c8c8c; }
.feature-card__action {
margin-top: 10px; display: inline-flex; align-items: center; justify-content: center; height: 28px;
padding: 0 12px; border-radius: 14px; background: #f5f7fa; color: #13c2c2; font-size: 12px; font-weight: 600;
}
.feature-card__arrow { font-size: 18px; color: #c6c6c6; flex-shrink: 0; }
.quick-grid {
display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin-top: 12px;
}
.quick-card {
background: #fff; border-radius: 16px; padding: 16px 14px; min-height: 118px;
box-shadow: 0 4px 14px rgba(0,0,0,0.05); border: 1px solid #f0f0f0;
position: relative; overflow: hidden;
}
.quick-card::after {
content: ''; position: absolute; right: -20px; bottom: -20px; width: 72px; height: 72px;
border-radius: 50%; background: rgba(19, 194, 194, 0.08);
}
.quick-card__icon {
width: 40px; height: 40px; border-radius: 12px; display: flex; align-items: center;
justify-content: center; margin-bottom: 12px;
}
.quick-card__title {
font-size: 15px; font-weight: 700; color: #1a1a1a; margin-bottom: 6px; position: relative; z-index: 1;
}
.quick-card__desc {
font-size: 12px; line-height: 1.6; color: #8c8c8c; position: relative; z-index: 1;
}
.bottom-spacer { height: 28px; }
</style>
</head>
<body>
<div class="phone-shell">
<div class="phone-status-bar">
<span class="time">12:00</span>
<div class="icons">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<div class="nav-bar">
<span class="nav-back" onclick="history.back()"></span>
<span class="nav-title">健康体检</span>
</div>
<div class="scroll-content">
<div class="page-body">
<div class="hero-card">
<div class="hero-card__badge">
<span>📋</span>
<span>年度体检计划</span>
</div>
<div class="hero-card__title">健康体检</div>
<div class="hero-card__desc">本年度暂未开展体检计划,请耐心等待</div>
<div class="pending-card">
<div class="pending-card__label">待查看的体检报告</div>
<div class="pending-card__value">2 份</div>
<div class="pending-card__meta">历史报告已生成,可前往体检报告查看既往体检记录与指标摘要。</div>
</div>
</div>
<div class="section-title">体检服务</div>
<div class="feature-list">
<!-- [交互] 点击跳转至: booking-type.html -->
<div class="feature-card" onclick="location.href='booking-type.html'">
<div class="feature-card__icon" style="background:#e6fffb; color:#13c2c2;">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="16" y1="2.5" x2="16" y2="6"/><line x1="8" y1="2.5" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M8 14h3"/><path d="M8 17h5"/></svg>
</div>
<div class="feature-card__content">
<div class="feature-card__title">体检预约</div>
<div class="feature-card__desc">查看预约入口与年度体检开放状态</div>
<span class="feature-card__action">体检预约</span>
</div>
<span class="feature-card__arrow"></span>
</div>
<!-- [交互] 点击跳转至: my-appointments.html -->
<div class="feature-card" onclick="location.href='my-appointments.html'">
<div class="feature-card__icon" style="background:#f6ffed; color:#52c41a;">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
</div>
<div class="feature-card__content">
<div class="feature-card__title">我的预约</div>
<div class="feature-card__desc">查看历史预约记录与当前预约状态</div>
<span class="feature-card__action">查看预约</span>
</div>
<span class="feature-card__arrow"></span>
</div>
<!-- [交互] 点击跳转至: reports.html -->
<div class="feature-card" onclick="location.href='reports.html'">
<div class="feature-card__icon" style="background:#fff7e6; color:#fa8c16;">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="14" y2="17"/></svg>
</div>
<div class="feature-card__content">
<div class="feature-card__title">体检报告</div>
<div class="feature-card__desc">查看历年体检报告与 AI 解读入口</div>
<span class="feature-card__action">查看报告</span>
</div>
<span class="feature-card__arrow"></span>
</div>
</div>
<div class="section-title">辅助入口</div>
<div class="quick-grid">
<!-- [交互] 操作流程讲解入口,当前为静态说明卡片 -->
<div class="quick-card">
<div class="quick-card__icon" style="background:#eef9ff; color:#1677ff;">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 8v4l2.5 2.5"/></svg>
</div>
<div class="quick-card__title">操作流程讲解</div>
<div class="quick-card__desc">查看体检计划开放后,预约、到院、报告查询等关键流程说明。</div>
</div>
<!-- [交互] 一键咨询入口,当前为静态说明卡片 -->
<div class="quick-card">
<div class="quick-card__icon" style="background:#f9f0ff; color:#722ed1;">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><path d="M8 9h8"/><path d="M8 13h5"/></svg>
</div>
<div class="quick-card__title">一键咨询</div>
<div class="quick-card__desc">对体检开放时间、项目内容和报告指标进行咨询。</div>
</div>
</div>
<div class="bottom-spacer"></div>
</div>
</div>
</div>
</body>
</html>
+392
View File
@@ -0,0 +1,392 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>健康体检</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #f0f2f5;
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
}
.phone-shell {
width: 390px;
min-height: 844px;
background: #f7f9fb;
border-radius: 44px;
box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
}
.phone-status-bar {
height: 44px;
background: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.phone-status-bar .time { font-size: 15px; font-weight: 700; }
.phone-status-bar .icons { display: flex; gap: 6px; align-items: center; }
.nav-bar {
height: 44px;
display: flex;
align-items: center;
padding: 0 16px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
position: relative;
}
.nav-back {
font-size: 18px;
color: #1a1a1a;
cursor: pointer;
line-height: 1;
text-decoration: none;
}
.nav-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 17px;
font-weight: 600;
color: #1a1a1a;
}
.scroll-content {
flex: 1;
overflow-y: auto;
background: linear-gradient(180deg, #f3fbfb 0%, #f7f9fb 160px, #f7f9fb 100%);
}
.scroll-content::-webkit-scrollbar { display: none; }
.page-body { padding: 16px; }
.hero-card {
background: linear-gradient(135deg, #ffffff 0%, #f3fffe 100%);
border: 1px solid #e3f6f4;
border-radius: 20px;
padding: 18px 16px;
box-shadow: 0 6px 18px rgba(20, 180, 176, 0.08);
}
.hero-card__badge {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 10px;
border-radius: 14px;
background: rgba(255, 170, 0, 0.12);
color: #d48806;
font-size: 12px;
font-weight: 600;
margin-bottom: 12px;
}
.hero-card__title {
font-size: 22px;
line-height: 1.2;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 10px;
}
.hero-card__desc {
font-size: 14px;
line-height: 1.7;
color: #666;
}
.fee-card {
margin-top: 14px;
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%);
border-radius: 18px;
padding: 16px;
color: #fff;
box-shadow: 0 10px 24px rgba(19, 194, 194, 0.22);
}
.fee-card__label {
font-size: 13px;
opacity: 0.9;
margin-bottom: 8px;
}
.fee-card__value {
font-size: 30px;
font-weight: 800;
line-height: 1;
letter-spacing: 0.5px;
}
.fee-card__meta {
margin-top: 10px;
font-size: 12px;
opacity: 0.86;
}
.section-title {
font-size: 16px;
font-weight: 700;
color: #1a1a1a;
margin: 22px 2px 12px;
}
.feature-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.feature-card {
background: #fff;
border-radius: 18px;
padding: 16px;
display: flex;
align-items: center;
gap: 14px;
box-shadow: 0 4px 14px rgba(0,0,0,0.05);
border: 1px solid #f0f0f0;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.feature-card:active { transform: scale(0.985); }
.feature-card__icon {
width: 52px;
height: 52px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.feature-card__content { flex: 1; min-width: 0; }
.feature-card__title {
font-size: 16px;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 4px;
}
.feature-card__desc {
font-size: 12px;
line-height: 1.6;
color: #8c8c8c;
}
.feature-card__action {
margin-top: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
padding: 0 12px;
border-radius: 14px;
background: #f5f7fa;
color: #13c2c2;
font-size: 12px;
font-weight: 600;
}
.feature-card__arrow {
font-size: 18px;
color: #c6c6c6;
flex-shrink: 0;
}
.quick-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-top: 12px;
}
.quick-card {
background: #fff;
border-radius: 16px;
padding: 16px 14px;
min-height: 118px;
box-shadow: 0 4px 14px rgba(0,0,0,0.05);
border: 1px solid #f0f0f0;
cursor: pointer;
transition: transform 0.15s ease;
position: relative;
overflow: hidden;
}
.quick-card:active { transform: scale(0.985); }
.quick-card::after {
content: '';
position: absolute;
right: -20px;
bottom: -20px;
width: 72px;
height: 72px;
border-radius: 50%;
background: rgba(19, 194, 194, 0.08);
}
.quick-card__icon {
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
}
.quick-card__title {
font-size: 15px;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 6px;
position: relative;
z-index: 1;
}
.quick-card__desc {
font-size: 12px;
line-height: 1.6;
color: #8c8c8c;
position: relative;
z-index: 1;
}
.tips-card {
margin-top: 16px;
background: #fff;
border-radius: 18px;
padding: 16px;
box-shadow: 0 4px 14px rgba(0,0,0,0.05);
border: 1px solid #f0f0f0;
}
.tips-card__title {
font-size: 15px;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 10px;
}
.tips-card__list {
display: flex;
flex-direction: column;
gap: 8px;
}
.tips-card__item {
font-size: 13px;
line-height: 1.6;
color: #666;
display: flex;
gap: 8px;
}
.tips-card__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #13c2c2;
margin-top: 7px;
flex-shrink: 0;
}
.bottom-spacer { height: 28px; }
</style>
</head>
<body>
<div class="phone-shell">
<div class="phone-status-bar">
<span class="time">12:00</span>
<div class="icons">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<div class="nav-bar">
<span class="nav-back" onclick="location.href='../home/index.html'"></span>
<span class="nav-title">健康体检</span>
</div>
<div class="scroll-content">
<div class="page-body">
<div class="hero-card">
<div class="hero-card__badge">
<span>📋</span>
<span>年度体检计划</span>
</div>
<!-- <div class="hero-card__title">健康体检</div>-->
<div class="hero-card__desc">本单位已开展体检计划,请尽快进行体检预约并合理安排时间前往医院进行体检。</div>
<div class="fee-card">
<div class="fee-card__label">本年度体检费用(元)</div>
<div class="fee-card__value">1500.00</div>
<!-- <div class="fee-card__meta">费用由单位统一配置,预约时自动关联体检项目。</div>-->
</div>
</div>
<div class="section-title">体检服务</div>
<div class="feature-list">
<!-- [交互] 点击跳转至: booking-type.html -->
<div class="feature-card" onclick="location.href='booking-type.html'">
<div class="feature-card__icon" style="background:#e6fffb; color:#13c2c2;">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="16" y1="2.5" x2="16" y2="6"/><line x1="8" y1="2.5" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M8 14h3"/><path d="M8 17h5"/></svg>
</div>
<div class="feature-card__content">
<div class="feature-card__title">体检预约</div>
<div class="feature-card__desc">选择体检医院和体检项目,预约体检日期</div>
<span class="feature-card__action">体检预约</span>
</div>
<span class="feature-card__arrow"></span>
</div>
<!-- [交互] 点击跳转至: my-appointments.html -->
<div class="feature-card" onclick="location.href='my-appointments.html'">
<div class="feature-card__icon" style="background:#f6ffed; color:#52c41a;">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
</div>
<div class="feature-card__content">
<div class="feature-card__title">我的预约</div>
<div class="feature-card__desc">查看/取消体检预约</div>
<span class="feature-card__action">查看预约</span>
</div>
<span class="feature-card__arrow"></span>
</div>
<!-- [交互] 点击跳转至: reports.html -->
<div class="feature-card" onclick="location.href='reports.html'">
<div class="feature-card__icon" style="background:#fff7e6; color:#fa8c16;">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="14" y2="17"/></svg>
</div>
<div class="feature-card__content">
<div class="feature-card__title">体检报告</div>
<div class="feature-card__desc">查看报告/ 解读结果</div>
<span class="feature-card__action">查看报告</span>
</div>
<span class="feature-card__arrow"></span>
</div>
</div>
<div class="section-title">辅助入口</div>
<div class="quick-grid">
<!-- [交互] 操作流程讲解入口,后续可跳转至体检流程说明页 -->
<div class="quick-card">
<div class="quick-card__icon" style="background:#eef9ff; color:#1677ff;">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 8v4l2.5 2.5"/></svg>
</div>
<div class="quick-card__title">操作流程讲解</div>
<div class="quick-card__desc">查看预约、到院、报告查询等关键流程说明。</div>
</div>
<!-- [交互] 一键咨询入口,点击跳转至 consult.html -->
<div class="quick-card" onclick="location.href='consult.html'">
<div class="quick-card__icon" style="background:#f9f0ff; color:#722ed1;">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><path d="M8 9h8"/><path d="M8 13h5"/></svg>
</div>
<div class="quick-card__title">一键咨询</div>
<div class="quick-card__desc">对体检项目、报告指标和异常结果发起咨询。</div>
</div>
</div>
<!-- <div class="tips-card">-->
<!-- <div class="tips-card__title">体检提醒</div>-->
<!-- <div class="tips-card__list">-->
<!-- <div class="tips-card__item"><span class="tips-card__dot"></span><span>预约成功后,请按预约日期前往对应医院完成检查。</span></div>-->
<!-- <div class="tips-card__item"><span class="tips-card__dot"></span><span>空腹类项目建议体检前一晚清淡饮食,避免饮酒和熬夜。</span></div>-->
<!-- <div class="tips-card__item"><span class="tips-card__dot"></span><span>报告生成后可在线查看,并结合解读结果关注异常指标。</span></div>-->
<!-- </div>-->
<!-- </div>-->
<div class="bottom-spacer"></div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,200 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>我的预约</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: #13c2c2;
--primary-dark: #0f9b9b;
--primary-light: #e6fffb;
--success-bg: #f6ffed;
--success-text: #52c41a;
--warning-bg: #fff7e6;
--warning-text: #fa8c16;
--danger-bg: #fff1f0;
--danger-text: #ff4d4f;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--text-muted: #8c8c8c;
--bg-page: #f0f2f5;
--bg-content: #f7f9fb;
--border: #e8f3f3;
--shadow: 0 10px 24px rgba(19, 194, 194, 0.06);
}
body {
background: var(--bg-page);
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
color: var(--text-primary);
}
.phone-shell {
width: 390px; min-height: 844px; background: var(--bg-content);
border-radius: 44px; box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0;
overflow: hidden; position: relative; display: flex; flex-direction: column;
}
.phone-status-bar {
height: 44px; background: #fff; display: flex; align-items: center;
justify-content: space-between; padding: 0 24px; font-size: 12px; font-weight: 600; flex-shrink: 0;
}
.phone-status-bar .time { font-size: 15px; font-weight: 700; }
.phone-status-bar .icons { display: flex; gap: 6px; align-items: center; }
.nav-bar {
height: 44px; display: flex; align-items: center; padding: 0 16px;
background: #fff; border-bottom: 1px solid #f0f0f0; flex-shrink: 0; position: relative;
}
.nav-back { font-size: 18px; color: var(--text-primary); cursor: pointer; line-height: 1; text-decoration: none; }
.nav-title { position: absolute; left: 50%; transform: translateX(-50%); font-size: 17px; font-weight: 600; color: var(--text-primary); }
.scroll-content {
flex: 1; overflow-y: auto;
background: linear-gradient(180deg, #f0fbfb 0%, #f7f9fb 140px, #f7f9fb 100%);
padding: 14px 16px 20px;
}
.scroll-content::-webkit-scrollbar { display: none; }
.tabs {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; padding: 6px;
background: rgba(255,255,255,0.92); border-radius: 18px;
box-shadow: 0 8px 20px rgba(19, 194, 194, 0.08);
position: sticky; top: 0; z-index: 5; backdrop-filter: blur(10px);
}
.tabs__item {
height: 36px; border: none; border-radius: 14px; background: transparent;
color: var(--text-secondary); font-size: 13px; font-weight: 600; cursor: pointer;
text-decoration: none; display: flex; align-items: center; justify-content: center;
}
.tabs__item--active {
background: var(--primary-light); color: var(--primary-dark);
box-shadow: inset 0 0 0 1px rgba(19, 194, 194, 0.16);
}
.summary-bar {
margin: 14px 0 12px; padding: 16px; border-radius: 20px;
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%);
color: #fff; box-shadow: 0 12px 24px rgba(19, 194, 194, 0.2);
}
.summary-bar__title { font-size: 18px; font-weight: 700; margin-bottom: 6px; }
.summary-bar__desc { font-size: 13px; line-height: 1.7; opacity: 0.92; }
.appointment-list { display: flex; flex-direction: column; gap: 12px; }
.appointment-card {
background: #fff; border-radius: 20px; padding: 16px;
border: 1px solid rgba(19, 194, 194, 0.06); box-shadow: var(--shadow);
}
.appointment-card[data-status="done"],
.appointment-card[data-status="cancelled"] { opacity: 0.94; }
.appointment-card__top { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 12px; }
.appointment-card__title { font-size: 16px; font-weight: 700; color: var(--text-primary); }
.appointment-card__status { flex-shrink: 0; padding: 5px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.appointment-card__status--pending { background: var(--warning-bg); color: var(--warning-text); }
.appointment-card__status--done { background: var(--success-bg); color: var(--success-text); }
.appointment-card__status--cancelled { background: var(--danger-bg); color: var(--danger-text); }
.appointment-card__meta { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 12px; margin-bottom: 12px; }
.meta-item { background: #f9fbfb; border-radius: 14px; padding: 10px 12px; border: 1px solid #f0f5f5; }
.meta-item__label { font-size: 11px; color: var(--text-muted); margin-bottom: 5px; }
.meta-item__value { font-size: 13px; color: var(--text-primary); font-weight: 600; line-height: 1.5; }
.appointment-card__progress { margin-bottom: 14px; }
.progress-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 12px; color: var(--text-secondary); }
.progress-bar { height: 8px; border-radius: 999px; background: #eef3f3; overflow: hidden; }
.progress-bar__fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, #13c2c2 0%, #5cdbd3 100%); }
.appointment-card__actions { display: flex; gap: 10px; }
.appointment-card__btn {
flex: 1; height: 40px; border-radius: 20px; font-size: 14px; font-weight: 600; border: none; cursor: pointer;
}
.appointment-card__btn--ghost { background: #f5f7fa; color: var(--text-secondary); border: 1px solid #ebeff2; }
.appointment-card__btn--primary {
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%);
color: #fff; box-shadow: 0 8px 20px rgba(19, 194, 194, 0.18);
}
</style>
</head>
<body>
<div class="phone-shell">
<div class="phone-status-bar">
<span class="time">12:00</span>
<div class="icons">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<div class="nav-bar">
<a class="nav-back" href="index.html">&#8249;</a>
<span class="nav-title">我的预约</span>
</div>
<div class="scroll-content">
<div class="tabs">
<a class="tabs__item tabs__item--active" href="my-appointments-all.html">全部</a>
<a class="tabs__item" href="my-appointments.html">待体检</a>
<a class="tabs__item" href="my-appointments-done.html">已完成</a>
<a class="tabs__item" href="my-appointments-cancelled.html">已取消</a>
</div>
<div class="appointment-list">
<!-- 待体检 -->
<div class="appointment-card" data-status="pending">
<div class="appointment-card__top">
<div class="appointment-card__title">常规体检</div>
<div class="appointment-card__status appointment-card__status--pending">待体检</div>
</div>
<div class="appointment-card__meta">
<div class="meta-item"><div class="meta-item__label">体检医院</div><div class="meta-item__value">宁夏宝石花医院</div></div>
<div class="meta-item"><div class="meta-item__label">预约日期</div><div class="meta-item__value">2026-03-23</div></div>
</div>
<div class="appointment-card__progress">
<div class="progress-head"><span>体检进度</span><span>5/26</span></div>
<div class="progress-bar"><div class="progress-bar__fill" style="width: 19.2%;"></div></div>
</div>
</div>
<!-- 已完成 -->
<div class="appointment-card" data-status="done">
<div class="appointment-card__top">
<div class="appointment-card__title">常规体检</div>
<div class="appointment-card__status appointment-card__status--done">已完成</div>
</div>
<div class="appointment-card__meta">
<div class="meta-item"><div class="meta-item__label">体检医院</div><div class="meta-item__value">宁夏宝石花医院</div></div>
<div class="meta-item"><div class="meta-item__label">预约日期</div><div class="meta-item__value">2026-03-23</div></div>
</div>
<div class="appointment-card__progress">
<div class="progress-head"><span>体检进度</span><span>26/26</span></div>
<div class="progress-bar"><div class="progress-bar__fill" style="width: 100%;"></div></div>
</div>
</div>
<!-- 已取消 -->
<div class="appointment-card" data-status="cancelled">
<div class="appointment-card__top">
<div class="appointment-card__title">胃肠镜体检</div>
<div class="appointment-card__status appointment-card__status--cancelled">已取消</div>
</div>
<div class="appointment-card__meta">
<div class="meta-item"><div class="meta-item__label">体检医院</div><div class="meta-item__value">宁夏宝石花医院</div></div>
<div class="meta-item"><div class="meta-item__label">预约日期</div><div class="meta-item__value">2026-03-10</div></div>
</div>
</div>
<!-- 待体检 -->
<div class="appointment-card" data-status="pending">
<div class="appointment-card__top">
<div class="appointment-card__title">常规体检</div>
<div class="appointment-card__status appointment-card__status--pending">待体检</div>
</div>
<div class="appointment-card__meta">
<div class="meta-item"><div class="meta-item__label">体检医院</div><div class="meta-item__value">宁夏宝石花医院</div></div>
<div class="meta-item"><div class="meta-item__label">预约日期</div><div class="meta-item__value">2026-03-23</div></div>
</div>
<div class="appointment-card__progress">
<div class="progress-head"><span>体检进度</span><span>0/26</span></div>
<div class="progress-bar"><div class="progress-bar__fill" style="width: 0%;"></div></div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,150 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>我的预约</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: #13c2c2;
--primary-dark: #0f9b9b;
--primary-light: #e6fffb;
--success-bg: #f6ffed;
--success-text: #52c41a;
--warning-bg: #fff7e6;
--warning-text: #fa8c16;
--danger-bg: #fff1f0;
--danger-text: #ff4d4f;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--text-muted: #8c8c8c;
--bg-page: #f0f2f5;
--bg-content: #f7f9fb;
--border: #e8f3f3;
--shadow: 0 10px 24px rgba(19, 194, 194, 0.06);
}
body {
background: var(--bg-page);
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
color: var(--text-primary);
}
.phone-shell {
width: 390px; min-height: 844px; background: var(--bg-content);
border-radius: 44px; box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0;
overflow: hidden; position: relative; display: flex; flex-direction: column;
}
.phone-status-bar {
height: 44px; background: #fff; display: flex; align-items: center;
justify-content: space-between; padding: 0 24px; font-size: 12px; font-weight: 600; flex-shrink: 0;
}
.phone-status-bar .time { font-size: 15px; font-weight: 700; }
.phone-status-bar .icons { display: flex; gap: 6px; align-items: center; }
.nav-bar {
height: 44px; display: flex; align-items: center; padding: 0 16px;
background: #fff; border-bottom: 1px solid #f0f0f0; flex-shrink: 0; position: relative;
}
.nav-back { font-size: 18px; color: var(--text-primary); cursor: pointer; line-height: 1; text-decoration: none; }
.nav-title { position: absolute; left: 50%; transform: translateX(-50%); font-size: 17px; font-weight: 600; color: var(--text-primary); }
.scroll-content {
flex: 1; overflow-y: auto;
background: linear-gradient(180deg, #f0fbfb 0%, #f7f9fb 140px, #f7f9fb 100%);
padding: 14px 16px 20px;
}
.scroll-content::-webkit-scrollbar { display: none; }
.tabs {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; padding: 6px;
background: rgba(255,255,255,0.92); border-radius: 18px;
box-shadow: 0 8px 20px rgba(19, 194, 194, 0.08);
position: sticky; top: 0; z-index: 5; backdrop-filter: blur(10px);
}
.tabs__item {
height: 36px; border: none; border-radius: 14px; background: transparent;
color: var(--text-secondary); font-size: 13px; font-weight: 600; cursor: pointer;
text-decoration: none; display: flex; align-items: center; justify-content: center;
}
.tabs__item--active {
background: var(--primary-light); color: var(--primary-dark);
box-shadow: inset 0 0 0 1px rgba(19, 194, 194, 0.16);
}
.summary-bar {
margin: 14px 0 12px; padding: 16px; border-radius: 20px;
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%);
color: #fff; box-shadow: 0 12px 24px rgba(19, 194, 194, 0.2);
}
.summary-bar__title { font-size: 18px; font-weight: 700; margin-bottom: 6px; }
.summary-bar__desc { font-size: 13px; line-height: 1.7; opacity: 0.92; }
.appointment-list { display: flex; flex-direction: column; gap: 12px; }
.appointment-card {
background: #fff; border-radius: 20px; padding: 16px;
border: 1px solid rgba(19, 194, 194, 0.06); box-shadow: var(--shadow); opacity: 0.94;
}
.appointment-card__top { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 12px; }
.appointment-card__title { font-size: 16px; font-weight: 700; color: var(--text-primary); }
.appointment-card__status { flex-shrink: 0; padding: 5px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.appointment-card__status--cancelled { background: var(--danger-bg); color: var(--danger-text); }
.appointment-card__meta { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 12px; margin-bottom: 12px; }
.meta-item { background: #f9fbfb; border-radius: 14px; padding: 10px 12px; border: 1px solid #f0f5f5; }
.meta-item__label { font-size: 11px; color: var(--text-muted); margin-bottom: 5px; }
.meta-item__value { font-size: 13px; color: var(--text-primary); font-weight: 600; line-height: 1.5; }
.appointment-card__actions { display: flex; gap: 10px; }
.appointment-card__btn {
flex: 1; height: 40px; border-radius: 20px; font-size: 14px; font-weight: 600; border: none; cursor: pointer;
}
.appointment-card__btn--ghost { background: #f5f7fa; color: var(--text-secondary); border: 1px solid #ebeff2; }
</style>
</head>
<body>
<div class="phone-shell">
<div class="phone-status-bar">
<span class="time">12:00</span>
<div class="icons">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<div class="nav-bar">
<a class="nav-back" href="index.html">&#8249;</a>
<span class="nav-title">我的预约</span>
</div>
<div class="scroll-content">
<div class="tabs">
<a class="tabs__item" href="my-appointments-all.html">全部</a>
<a class="tabs__item" href="my-appointments.html">待体检</a>
<a class="tabs__item" href="my-appointments-done.html">已完成</a>
<a class="tabs__item tabs__item--active" href="my-appointments-cancelled.html">已取消</a>
</div>
<div class="appointment-list">
<a class="appointment-card" href="my-appointments-detail-cancelled.html" style="text-decoration:none;color:inherit;">
<div class="appointment-card__top">
<div class="appointment-card__title">胃肠镜体检</div>
<div class="appointment-card__status appointment-card__status--cancelled">已取消</div>
</div>
<div class="appointment-card__meta">
<div class="meta-item"><div class="meta-item__label">体检医院</div><div class="meta-item__value">宁夏宝石花医院</div></div>
<div class="meta-item"><div class="meta-item__label">预约日期</div><div class="meta-item__value">2026-03-10</div></div>
</div>
</a>
<a class="appointment-card" href="my-appointments-detail-cancelled.html" style="text-decoration:none;color:inherit;">
<div class="appointment-card__top">
<div class="appointment-card__title">常规体检</div>
<div class="appointment-card__status appointment-card__status--cancelled">已取消</div>
</div>
<div class="appointment-card__meta">
<div class="meta-item"><div class="meta-item__label">体检医院</div><div class="meta-item__value">宁夏宝石花医院</div></div>
<div class="meta-item"><div class="meta-item__label">预约日期</div><div class="meta-item__value">2026-01-15</div></div>
</div>
</a>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,439 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>体检预约详情</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: #13c2c2;
--primary-dark: #0f9b9b;
--primary-light: #e6fffb;
--success-light: #f6ffed;
--success-text: #52c41a;
--danger: #ff4d4f;
--danger-light: #fff1f0;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--text-muted: #8c8c8c;
--border: #e8f3f3;
--bg-page: #f0f2f5;
--bg-content: #f7f9fb;
--shadow: 0 10px 30px rgba(19, 194, 194, 0.08);
}
body {
background: var(--bg-page);
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
color: var(--text-primary);
}
.phone-shell {
width: 390px;
min-height: 844px;
background: var(--bg-content);
border-radius: 44px;
box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
}
.phone-status-bar {
height: 44px;
background: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.phone-status-bar .time { font-size: 15px; font-weight: 700; }
.phone-status-bar .icons { display: flex; gap: 6px; align-items: center; }
.nav-bar {
height: 44px;
display: flex;
align-items: center;
padding: 0 16px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
position: relative;
}
.nav-back {
font-size: 18px;
color: var(--text-primary);
cursor: pointer;
line-height: 1;
text-decoration: none;
}
.nav-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 17px;
font-weight: 600;
color: var(--text-primary);
}
.scroll-content {
flex: 1;
overflow-y: auto;
background: linear-gradient(180deg, #effbfb 0%, #f7f9fb 180px, #f7f9fb 100%);
padding-bottom: 120px;
}
.scroll-content::-webkit-scrollbar { display: none; }
.page-body { padding: 16px; }
/* 预约状态栏 */
.status-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
margin-bottom: 14px;
}
.status-bar__left {
display: flex;
align-items: center;
gap: 8px;
}
.status-bar__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #fa8c16;
}
.status-bar__text {
font-size: 16px;
font-weight: 700;
color: #fa8c16;
}
.status-card {
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%);
border-radius: 24px;
padding: 22px 18px;
color: #fff;
box-shadow: 0 12px 28px rgba(19, 194, 194, 0.24);
}
.status-card__badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(255,255,255,0.18);
font-size: 12px;
font-weight: 600;
margin-bottom: 14px;
}
.status-card__title {
font-size: 24px;
font-weight: 700;
line-height: 1.25;
margin-bottom: 8px;
}
.status-card__desc {
font-size: 13px;
line-height: 1.7;
opacity: 0.92;
}
.summary-card,
.info-card {
background: #fff;
border-radius: 20px;
padding: 18px 16px;
margin-top: 14px;
margin-top: 14px;
box-shadow: var(--shadow);
border: 1px solid rgba(19, 194, 194, 0.06);
}
.section-title {
font-size: 16px;
font-weight: 700;
margin-bottom: 14px;
}
.summary-list,
.info-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.summary-item,
.info-item {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.summary-item__label,
.info-item__label {
font-size: 13px;
color: var(--text-muted);
flex-shrink: 0;
}
.summary-item__value,
.info-item__value {
font-size: 14px;
color: var(--text-primary);
font-weight: 600;
text-align: right;
line-height: 1.6;
}
.summary-item--fee {
margin-top: 2px;
padding: 14px;
border-radius: 18px;
background: linear-gradient(135deg, #f0fffe 0%, #f7fffd 100%);
border: 1px solid #d7f5f3;
align-items: center;
}
.summary-item--fee .summary-item__label {
color: var(--primary-dark);
font-weight: 600;
}
.summary-item--fee .summary-item__value {
color: var(--primary-dark);
font-size: 24px;
font-weight: 800;
}
.person-card {
margin-top: 14px;
background: linear-gradient(135deg, #ffffff 0%, #f8fffe 100%);
border-radius: 20px;
padding: 16px;
border: 1px solid #e4f7f5;
box-shadow: var(--shadow);
}
.person-card__header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.person-card__avatar {
width: 48px;
height: 48px;
border-radius: 16px;
background: linear-gradient(135deg, #13c2c2 0%, #5cdbd3 100%);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 700;
flex-shrink: 0;
}
.person-card__name {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 4px;
}
.person-card__meta {
font-size: 12px;
color: var(--text-muted);
}
.tips-card {
margin-top: 14px;
background: #fff;
border-radius: 20px;
padding: 16px;
border: 1px solid #eef5f5;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.04);
}
.tips-card__title {
font-size: 15px;
font-weight: 700;
margin-bottom: 10px;
}
.tips-card__item {
display: flex;
gap: 8px;
font-size: 13px;
line-height: 1.7;
color: var(--text-secondary);
margin-bottom: 8px;
}
.tips-card__item:last-child { margin-bottom: 0; }
.tips-card__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--primary);
margin-top: 8px;
flex-shrink: 0;
}
/* 已选体检项目 */
.project-section {
background: #fff; border-radius: 20px; padding: 18px 16px;
box-shadow: var(--shadow); border: 1px solid rgba(19,194,194,0.06); margin-top: 14px;
}
.project-section__header {
display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px;
}
.project-section__count { font-size: 13px; color: var(--text-muted); }
.project-section__count em { font-style: normal; color: var(--primary-dark); font-weight: 700; }
.project-tabs {
display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px;
padding: 5px; background: #f5f7f7; border-radius: 14px; margin-bottom: 14px;
}
.project-tab {
height: 34px; border: none; border-radius: 10px; background: transparent;
color: var(--text-secondary); font-size: 13px; font-weight: 600; cursor: pointer;
}
.project-tab--active {
background: #fff; color: var(--primary-dark);
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.project-list-panel {
max-height: 280px; overflow-y: auto; padding-right: 2px;
}
.project-list-panel::-webkit-scrollbar { display: none; }
.project-list { display: flex; flex-direction: column; gap: 10px; }
.project-item {
display: flex; align-items: center; gap: 12px; padding: 12px 14px;
background: #f9fbfb; border-radius: 14px; border: 1px solid #eef5f5;
}
.project-item__dot {
width: 8px; height: 8px; border-radius: 50%; background: var(--primary); flex-shrink: 0;
}
.project-item__info { flex: 1; }
.project-item__name { font-size: 14px; font-weight: 600; margin-bottom: 2px; }
.project-item__desc { font-size: 12px; color: var(--text-muted); }
.project-item__price { font-size: 13px; font-weight: 700; color: var(--primary-dark); flex-shrink: 0; }
.project-item__badge {
display: inline-flex; align-items: center; height: 20px; padding: 0 8px;
border-radius: 10px; font-size: 10px; font-weight: 600; flex-shrink: 0;
}
.project-item__badge--normal { background: #f5f5f5; color: var(--text-muted); }
.project-item__badge--key { background: #fff1f0; color: #ff4d4f; }
</style>
</head>
<body>
<div class="phone-shell">
<div class="phone-status-bar">
<span class="time">12:00</span>
<div class="icons">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<div class="nav-bar">
<!-- [交互] 点击返回至: my-appointments-cancelled.html -->
<a class="nav-back" href="my-appointments-cancelled.html"></a>
<span class="nav-title">体检预约详情</span>
</div>
<div class="scroll-content">
<!-- 预约状态栏 -->
<div class="status-bar">
<div class="status-bar__left">
<div class="status-bar__dot" style="background:#ff4d4f;"></div>
<div class="status-bar__text" style="color:#ff4d4f;">已取消</div>
</div>
</div>
<div class="summary-card">
<div class="section-title">常规体检</div>
<div class="summary-list">
<div class="summary-item">
<div class="summary-item__label">体检医院</div>
<div class="summary-item__value">宁夏宝石花医院</div>
</div>
<div class="summary-item">
<div class="summary-item__label">预约日期</div>
<div class="summary-item__value">2026-03-23</div>
</div>
<div class="summary-item">
<div class="summary-item__label">体检员工</div>
<div class="summary-item__value">张旭</div>
</div>
<div class="summary-item">
<div class="summary-item__label">单位部门</div>
<div class="summary-item__value">采油一厂·xx作业区</div>
</div>
</div>
</div>
<!-- 已选体检项目 -->
<div class="project-section">
<div class="project-section__header">
<div class="section-title" style="margin-bottom:0">已选体检项目</div>
<span class="project-section__count"><em id="totalCount">14</em></span>
</div>
<!-- [交互] 点击切换自选项目/必检项目 -->
<div class="project-tabs">
<button class="project-tab project-tab--active" type="button" data-tab="optional">自选项目(6)</button>
<button class="project-tab" type="button" data-tab="required">必检项目(8)</button>
</div>
<div class="project-list-panel">
<div class="project-list" id="projectList"></div>
</div>
</div>
</div>
</div>
<script>
const projects = [
{ name: '一般检查', desc: '身高、体重、血压、视力', price: 120, type: 'required', level: 'normal' },
{ name: '血常规', desc: '血红蛋白、白细胞、血小板等', price: 80, type: 'required', level: 'normal' },
{ name: '尿常规', desc: '尿蛋白、尿糖、尿潜血等', price: 45, type: 'required', level: 'normal' },
{ name: '肝功能', desc: 'ALT、AST、总胆红素等', price: 160, type: 'required', level: 'key' },
{ name: '肾功能', desc: '肌酐、尿素氮、尿酸等', price: 110, type: 'required', level: 'key' },
{ name: '血脂四项', desc: '总胆固醇、甘油三酯、HDL、LDL', price: 90, type: 'required', level: 'normal' },
{ name: '空腹血糖', desc: '空腹静脉血糖检测', price: 40, type: 'required', level: 'normal' },
{ name: '心电图', desc: '十二导联心电图检查', price: 75, type: 'required', level: 'key' },
{ name: '胸部X光', desc: '胸部正位X线摄影', price: 140, type: 'optional', level: 'normal' },
{ name: '腹部彩超', desc: '肝胆脾胰肾彩色超声', price: 180, type: 'optional', level: 'key' },
{ name: '甲状腺彩超', desc: '甲状腺彩色多普勒超声', price: 150, type: 'optional', level: 'key' },
{ name: '肿瘤标志物', desc: 'AFP、CEA、CA199等', price: 280, type: 'optional', level: 'key' },
{ name: '幽门螺杆菌', desc: 'C14呼气试验', price: 120, type: 'optional', level: 'normal' },
{ name: '骨密度检测', desc: '超声骨密度检测', price: 100, type: 'optional', level: 'normal' }
];
let activeTab = 'optional';
const listEl = document.getElementById('projectList');
const tabButtons = document.querySelectorAll('.project-tab');
function renderProjects() {
const filtered = projects.filter(item => item.type === activeTab);
listEl.innerHTML = filtered.map(item => {
const badgeText = item.level === 'key' ? '重点' : '一般';
const badgeClass = item.level === 'key' ? 'project-item__badge--key' : 'project-item__badge--normal';
return `
<div class="project-item">
<div class="project-item__dot"></div>
<div class="project-item__info">
<div class="project-item__name">${item.name}</div>
<div class="project-item__desc">${item.desc}</div>
</div>
<div class="project-item__price">¥${item.price}</div>
<div class="project-item__badge ${badgeClass}">${badgeText}</div>
</div>
`;
}).join('');
}
tabButtons.forEach(btn => {
btn.addEventListener('click', () => {
tabButtons.forEach(item => item.classList.remove('project-tab--active'));
btn.classList.add('project-tab--active');
activeTab = btn.dataset.tab;
renderProjects();
});
});
renderProjects();
</script>
</body>
</html>
@@ -0,0 +1,668 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>体检预约详情</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: #13c2c2;
--primary-dark: #0f9b9b;
--primary-light: #e6fffb;
--success-light: #f6ffed;
--success-text: #52c41a;
--danger: #ff4d4f;
--danger-light: #fff1f0;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--text-muted: #8c8c8c;
--border: #e8f3f3;
--bg-page: #f0f2f5;
--bg-content: #f7f9fb;
--shadow: 0 10px 30px rgba(19, 194, 194, 0.08);
}
body {
background: var(--bg-page);
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
color: var(--text-primary);
}
.phone-shell {
width: 390px;
min-height: 844px;
background: var(--bg-content);
border-radius: 44px;
box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
}
.phone-status-bar {
height: 44px;
background: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.phone-status-bar .time { font-size: 15px; font-weight: 700; }
.phone-status-bar .icons { display: flex; gap: 6px; align-items: center; }
.nav-bar {
height: 44px;
display: flex;
align-items: center;
padding: 0 16px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
position: relative;
}
.nav-back {
font-size: 18px;
color: var(--text-primary);
cursor: pointer;
line-height: 1;
text-decoration: none;
}
.nav-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 17px;
font-weight: 600;
color: var(--text-primary);
}
.scroll-content {
flex: 1;
overflow-y: auto;
background: linear-gradient(180deg, #effbfb 0%, #f7f9fb 180px, #f7f9fb 100%);
padding-bottom: 120px;
}
.scroll-content::-webkit-scrollbar { display: none; }
.page-body { padding: 16px; }
/* 预约状态栏 */
.status-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
margin-bottom: 14px;
}
.status-bar__left {
display: flex;
align-items: center;
gap: 8px;
}
.status-bar__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #fa8c16;
}
.status-bar__text {
font-size: 16px;
font-weight: 700;
color: #fa8c16;
}
.status-bar__btn {
padding: 6px 14px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
border: 1px solid rgba(255, 77, 79, 0.3);
background: var(--danger-light);
color: var(--danger);
cursor: pointer;
}
.status-bar__btn--disabled {
border: 1px solid #d9d9d9;
background: #f5f5f5;
color: #bfbfbf;
cursor: not-allowed;
}
/* 确认弹窗 */
.confirm-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 200;
justify-content: center;
align-items: center;
}
.confirm-overlay--visible { display: flex; }
.confirm-dialog {
width: 300px;
background: #fff;
border-radius: 20px;
padding: 24px 20px 18px;
text-align: center;
}
.confirm-dialog__title {
font-size: 17px;
font-weight: 700;
margin-bottom: 14px;
color: var(--text-primary);
}
.confirm-dialog__info {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 20px;
padding: 12px;
background: #f9fbfb;
border-radius: 12px;
border: 1px solid #f0f5f5;
}
.confirm-dialog__row {
display: flex;
justify-content: space-between;
font-size: 13px;
}
.confirm-dialog__label { color: var(--text-muted); }
.confirm-dialog__value { color: var(--text-primary); font-weight: 600; }
.confirm-dialog__actions {
display: flex;
gap: 10px;
}
.confirm-dialog__btn {
flex: 1;
height: 42px;
border-radius: 21px;
font-size: 14px;
font-weight: 600;
border: none;
cursor: pointer;
}
.confirm-dialog__btn--cancel {
background: #f5f7fa;
color: var(--text-secondary);
border: 1px solid #ebeff2;
}
.confirm-dialog__btn--confirm {
background: var(--danger);
color: #fff;
}
.toast {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0,0,0,0.76);
color: #fff;
padding: 14px 24px;
border-radius: 12px;
font-size: 14px;
z-index: 200;
text-align: center;
max-width: 280px;
line-height: 1.6;
display: none;
}
.toast--visible { display: block; }
.status-card {
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%);
border-radius: 24px;
padding: 22px 18px;
color: #fff;
box-shadow: 0 12px 28px rgba(19, 194, 194, 0.24);
}
.status-card__badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(255,255,255,0.18);
font-size: 12px;
font-weight: 600;
margin-bottom: 14px;
}
.status-card__title {
font-size: 24px;
font-weight: 700;
line-height: 1.25;
margin-bottom: 8px;
}
.status-card__desc {
font-size: 13px;
line-height: 1.7;
opacity: 0.92;
}
.summary-card,
.info-card {
background: #fff;
border-radius: 20px;
padding: 18px 16px;
margin-top: 14px;
margin-top: 14px;
box-shadow: var(--shadow);
border: 1px solid rgba(19, 194, 194, 0.06);
}
.section-title {
font-size: 16px;
font-weight: 700;
margin-bottom: 14px;
}
.summary-list,
.info-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.summary-item,
.info-item {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.summary-item__label,
.info-item__label {
font-size: 13px;
color: var(--text-muted);
flex-shrink: 0;
}
.summary-item__value,
.info-item__value {
font-size: 14px;
color: var(--text-primary);
font-weight: 600;
text-align: right;
line-height: 1.6;
}
.summary-item--fee {
margin-top: 2px;
padding: 14px;
border-radius: 18px;
background: linear-gradient(135deg, #f0fffe 0%, #f7fffd 100%);
border: 1px solid #d7f5f3;
align-items: center;
}
.summary-item--fee .summary-item__label {
color: var(--primary-dark);
font-weight: 600;
}
.summary-item--fee .summary-item__value {
color: var(--primary-dark);
font-size: 24px;
font-weight: 800;
}
.person-card {
margin-top: 14px;
background: linear-gradient(135deg, #ffffff 0%, #f8fffe 100%);
border-radius: 20px;
padding: 16px;
border: 1px solid #e4f7f5;
box-shadow: var(--shadow);
}
.person-card__header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.person-card__avatar {
width: 48px;
height: 48px;
border-radius: 16px;
background: linear-gradient(135deg, #13c2c2 0%, #5cdbd3 100%);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 700;
flex-shrink: 0;
}
.person-card__name {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 4px;
}
.person-card__meta {
font-size: 12px;
color: var(--text-muted);
}
.tips-card {
margin-top: 14px;
background: #fff;
border-radius: 20px;
padding: 16px;
border: 1px solid #eef5f5;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.04);
}
.tips-card__title {
font-size: 15px;
font-weight: 700;
margin-bottom: 10px;
}
.tips-card__item {
display: flex;
gap: 8px;
font-size: 13px;
line-height: 1.7;
color: var(--text-secondary);
margin-bottom: 8px;
}
.tips-card__item:last-child { margin-bottom: 0; }
.tips-card__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--primary);
margin-top: 8px;
flex-shrink: 0;
}
.qrcode-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.qrcode-wrap__img {
width: 160px;
height: 160px;
border-radius: 12px;
border: 1px solid #eef5f5;
cursor: pointer;
transition: transform 0.2s;
}
.qrcode-wrap__img:hover { transform: scale(1.04); }
.qrcode-wrap__hint {
font-size: 12px;
color: var(--text-muted);
}
/* 二维码放大遮罩 */
.qr-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 100;
justify-content: center;
align-items: center;
cursor: pointer;
}
.qr-overlay--visible { display: flex; }
.qr-overlay__img {
width: 260px;
height: 260px;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
/* 体检进度 */
.progress-card {
margin-top: 14px;
background: #fff;
border-radius: 20px;
padding: 18px 16px;
box-shadow: var(--shadow);
border: 1px solid rgba(19, 194, 194, 0.06);
}
.progress-card__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.progress-card__title {
font-size: 16px;
font-weight: 700;
}
.progress-card__ratio {
font-size: 13px;
color: var(--primary-dark);
font-weight: 600;
}
.progress-bar-lg {
height: 10px;
border-radius: 999px;
background: #eef3f3;
overflow: hidden;
margin-bottom: 16px;
}
.progress-bar-lg__fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #13c2c2 0%, #5cdbd3 100%);
}
.project-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 14px;
}
.project-tabs__btn {
height: 34px;
border: none;
border-radius: 12px;
background: #f5f7fa;
color: var(--text-secondary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.project-tabs__btn--active {
background: var(--primary-light);
color: var(--primary-dark);
box-shadow: inset 0 0 0 1px rgba(19, 194, 194, 0.16);
}
.project-list { display: flex; flex-direction: column; gap: 8px; }
.project-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 14px;
background: #f9fbfb;
border: 1px solid #f0f5f5;
}
.project-item__icon {
width: 28px;
height: 28px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 14px;
}
.project-item__icon--done {
background: var(--success-light);
color: var(--success-text);
}
.project-item__icon--pending {
background: #fff7e6;
color: #fa8c16;
}
.project-item__name {
flex: 1;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.project-item__status {
font-size: 12px;
font-weight: 600;
}
.project-item__status--done { color: var(--success-text); }
.project-item__status--pending { color: #fa8c16; }
.hidden { display: none !important; }
.bottom-actions {
position: absolute;
left: 16px;
right: 16px;
bottom: 18px;
display: flex;
gap: 12px;
padding-bottom: 16px;
}
.bottom-actions__btn {
flex: 1;
height: 48px;
border-radius: 24px;
font-size: 15px;
font-weight: 600;
border: none;
cursor: pointer;
}
.bottom-actions__btn--ghost {
background: var(--danger-light);
color: var(--danger);
border: 1px solid rgba(255, 77, 79, 0.2);
}
.bottom-actions__btn--primary {
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%);
color: #fff;
box-shadow: 0 10px 20px rgba(19, 194, 194, 0.22);
}
</style>
</head>
<body>
<div class="phone-shell">
<div class="phone-status-bar">
<span class="time">12:00</span>
<div class="icons">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<div class="nav-bar">
<!-- [交互] 点击返回至: my-appointments-done.html -->
<a class="nav-back" href="my-appointments-done.html"></a>
<span class="nav-title">体检预约详情</span>
</div>
<div class="scroll-content">
<!-- 预约状态栏 -->
<div class="status-bar">
<div class="status-bar__left">
<div class="status-bar__dot" style="background:#52c41a;"></div>
<div class="status-bar__text" style="color:#52c41a;">已完成</div>
</div>
</div>
<div class="summary-card">
<div class="section-title">常规体检</div>
<div class="summary-list">
<div class="summary-item">
<div class="summary-item__label">体检医院</div>
<div class="summary-item__value">宁夏宝石花医院</div>
</div>
<div class="summary-item">
<div class="summary-item__label">预约日期</div>
<div class="summary-item__value">2026-03-23</div>
</div>
<div class="summary-item">
<div class="summary-item__label">体检员工</div>
<div class="summary-item__value">张旭</div>
</div>
<div class="summary-item">
<div class="summary-item__label">单位部门</div>
<div class="summary-item__value">采油一厂·xx作业区</div>
</div>
</div>
</div>
<!-- 体检进度 -->
<div class="progress-card">
<div class="progress-card__header">
<div class="progress-card__title">体检进度</div>
<div class="progress-card__ratio" id="progressRatio">26 / 26 项</div>
</div>
<div class="progress-bar-lg"><div class="progress-bar-lg__fill" id="progressFill" style="width: 100%;"></div></div>
<!-- [交互] 点击切换已检/未检 Tab -->
<div class="project-tabs" id="projectTabs">
<button class="project-tabs__btn project-tabs__btn--active" type="button" id="tabDone" onclick="switchTab(0)">已检项目 (0)</button>
<button class="project-tabs__btn" type="button" id="tabPending" onclick="switchTab(1)">未检项目 (0)</button>
</div>
<!-- 已检项目列表(JS动态生成) -->
<div class="project-list" id="doneList"></div>
<!-- 未检项目列表(默认隐藏,JS动态生成) -->
<div class="project-list hidden" id="pendingList"></div>
</div>
</div>
</div>
<!-- <div class="bottom-actions">-->
<!-- &lt;!&ndash; [交互] 点击跳转至: my-appointments.html &ndash;&gt;-->
<!-- <button class="bottom-actions__btn bottom-actions__btn&#45;&#45;ghost" onclick="window.location.href='my-appointments.html'">取消预约</button>-->
<!-- &lt;!&ndash; [交互] 点击跳转至: hospital-checkin.html &ndash;&gt;-->
<!-- <button class="bottom-actions__btn bottom-actions__btn&#45;&#45;primary" onclick="window.location.href='hospital-checkin.html'">到院体检</button>-->
<!-- </div>-->
<!-- </div>-->
<!-- 二维码放大遮罩 -->
<div class="qr-overlay" id="qrOverlay" onclick="this.classList.remove('qr-overlay--visible')">
<svg class="qr-overlay__img" viewBox="0 0 160 160" xmlns="http://www.w3.org/2000/svg">
<rect width="160" height="160" fill="#fff"/>
<rect x="12" y="12" width="40" height="40" rx="4" fill="#1a1a1a"/><rect x="18" y="18" width="28" height="28" rx="2" fill="#fff"/><rect x="24" y="24" width="16" height="16" rx="2" fill="#1a1a1a"/>
<rect x="108" y="12" width="40" height="40" rx="4" fill="#1a1a1a"/><rect x="114" y="18" width="28" height="28" rx="2" fill="#fff"/><rect x="120" y="24" width="16" height="16" rx="2" fill="#1a1a1a"/>
<rect x="12" y="108" width="40" height="40" rx="4" fill="#1a1a1a"/><rect x="18" y="114" width="28" height="28" rx="2" fill="#fff"/><rect x="24" y="120" width="16" height="16" rx="2" fill="#1a1a1a"/>
<rect x="60" y="12" width="8" height="8" fill="#1a1a1a"/><rect x="76" y="12" width="8" height="8" fill="#1a1a1a"/><rect x="92" y="12" width="8" height="8" fill="#1a1a1a"/>
<rect x="60" y="24" width="8" height="8" fill="#1a1a1a"/><rect x="84" y="24" width="8" height="8" fill="#1a1a1a"/>
<rect x="68" y="36" width="8" height="8" fill="#1a1a1a"/><rect x="92" y="36" width="8" height="8" fill="#1a1a1a"/>
<rect x="12" y="60" width="8" height="8" fill="#1a1a1a"/><rect x="28" y="60" width="8" height="8" fill="#1a1a1a"/><rect x="44" y="60" width="8" height="8" fill="#1a1a1a"/><rect x="68" y="60" width="8" height="8" fill="#1a1a1a"/><rect x="92" y="60" width="8" height="8" fill="#1a1a1a"/><rect x="108" y="60" width="8" height="8" fill="#1a1a1a"/><rect x="132" y="60" width="8" height="8" fill="#1a1a1a"/>
<rect x="20" y="72" width="8" height="8" fill="#1a1a1a"/><rect x="44" y="72" width="8" height="8" fill="#1a1a1a"/><rect x="60" y="72" width="8" height="8" fill="#1a1a1a"/><rect x="84" y="72" width="8" height="8" fill="#1a1a1a"/><rect x="108" y="72" width="8" height="8" fill="#1a1a1a"/><rect x="124" y="72" width="8" height="8" fill="#1a1a1a"/>
<rect x="12" y="84" width="8" height="8" fill="#1a1a1a"/><rect x="36" y="84" width="8" height="8" fill="#1a1a1a"/><rect x="60" y="84" width="8" height="8" fill="#1a1a1a"/><rect x="76" y="84" width="8" height="8" fill="#1a1a1a"/><rect x="100" y="84" width="8" height="8" fill="#1a1a1a"/><rect x="140" y="84" width="8" height="8" fill="#1a1a1a"/>
<rect x="28" y="96" width="8" height="8" fill="#1a1a1a"/><rect x="52" y="96" width="8" height="8" fill="#1a1a1a"/><rect x="68" y="96" width="8" height="8" fill="#1a1a1a"/><rect x="92" y="96" width="8" height="8" fill="#1a1a1a"/><rect x="116" y="96" width="8" height="8" fill="#1a1a1a"/><rect x="140" y="96" width="8" height="8" fill="#1a1a1a"/>
<rect x="60" y="108" width="8" height="8" fill="#1a1a1a"/><rect x="76" y="108" width="8" height="8" fill="#1a1a1a"/><rect x="100" y="108" width="8" height="8" fill="#1a1a1a"/><rect x="124" y="108" width="8" height="8" fill="#1a1a1a"/>
<rect x="60" y="120" width="8" height="8" fill="#1a1a1a"/><rect x="84" y="120" width="8" height="8" fill="#1a1a1a"/><rect x="108" y="120" width="8" height="8" fill="#1a1a1a"/><rect x="140" y="120" width="8" height="8" fill="#1a1a1a"/>
<rect x="60" y="132" width="8" height="8" fill="#1a1a1a"/><rect x="76" y="132" width="8" height="8" fill="#1a1a1a"/><rect x="92" y="132" width="8" height="8" fill="#1a1a1a"/><rect x="116" y="132" width="8" height="8" fill="#1a1a1a"/><rect x="140" y="132" width="8" height="8" fill="#1a1a1a"/>
<rect x="68" y="140" width="8" height="8" fill="#1a1a1a"/><rect x="100" y="140" width="8" height="8" fill="#1a1a1a"/><rect x="132" y="140" width="8" height="8" fill="#1a1a1a"/>
</svg>
</div>
<script>
function switchTab(idx) {
var btns = document.querySelectorAll('.project-tabs__btn');
btns.forEach(function(b) { b.classList.remove('project-tabs__btn--active'); });
btns[idx].classList.add('project-tabs__btn--active');
document.getElementById('doneList').classList.toggle('hidden', idx !== 0);
document.getElementById('pendingList').classList.toggle('hidden', idx !== 1);
}
var totalCount = 26;
var doneCount = 26;
var allProjects = [
'一般检查', '内科', '外科', '眼科检查', '耳鼻喉科', '口腔科',
'血常规', '尿常规', '肝功能', '肾功能', '血脂四项', '空腹血糖',
'甲状腺功能', '肿瘤标志物', '心电图', '胸部DR', '腹部彩超',
'甲状腺彩超', '泌尿系彩超', '心脏彩超', '颈动脉彩超',
'骨密度检测', '肺功能', '幽门螺杆菌', '中医体质辨识', '总检报告'
];
function renderItem(name) {
return '<div class="project-item">' +
'<div class="project-item__icon project-item__icon--done">&#10003;</div>' +
'<div class="project-item__name">' + name + '</div>' +
'<div class="project-item__status project-item__status--done">已完成</div></div>';
}
document.getElementById('doneList').innerHTML = allProjects.map(function(n) { return renderItem(n); }).join('');
document.getElementById('pendingList').innerHTML = '<div class="project-item"><div class="project-item__icon project-item__icon--done">&#10003;</div><div class="project-item__name">当前预约项目已全部完成</div><div class="project-item__status project-item__status--done">完成</div></div>';
document.getElementById('tabDone').textContent = '已检项目 (' + doneCount + ')';
document.getElementById('tabPending').textContent = '未检项目 (0)';
document.getElementById('progressRatio').textContent = doneCount + ' / ' + totalCount + ' 项';
document.getElementById('progressFill').style.width = '100%';
</script>
</body>
</html>
@@ -0,0 +1,771 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>体检预约详情</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: #13c2c2;
--primary-dark: #0f9b9b;
--primary-light: #e6fffb;
--success-light: #f6ffed;
--success-text: #52c41a;
--danger: #ff4d4f;
--danger-light: #fff1f0;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--text-muted: #8c8c8c;
--border: #e8f3f3;
--bg-page: #f0f2f5;
--bg-content: #f7f9fb;
--shadow: 0 10px 30px rgba(19, 194, 194, 0.08);
}
body {
background: var(--bg-page);
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
color: var(--text-primary);
}
.phone-shell {
width: 390px;
min-height: 844px;
background: var(--bg-content);
border-radius: 44px;
box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
}
.phone-status-bar {
height: 44px;
background: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.phone-status-bar .time { font-size: 15px; font-weight: 700; }
.phone-status-bar .icons { display: flex; gap: 6px; align-items: center; }
.nav-bar {
height: 44px;
display: flex;
align-items: center;
padding: 0 16px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
position: relative;
}
.nav-back {
font-size: 18px;
color: var(--text-primary);
cursor: pointer;
line-height: 1;
text-decoration: none;
}
.nav-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 17px;
font-weight: 600;
color: var(--text-primary);
}
.scroll-content {
flex: 1;
overflow-y: auto;
background: linear-gradient(180deg, #effbfb 0%, #f7f9fb 180px, #f7f9fb 100%);
padding-bottom: 120px;
}
.scroll-content::-webkit-scrollbar { display: none; }
.page-body { padding: 16px; }
/* 预约状态栏 */
.status-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
margin-bottom: 14px;
}
.status-bar__left {
display: flex;
align-items: center;
gap: 8px;
}
.status-bar__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #fa8c16;
}
.status-bar__text {
font-size: 16px;
font-weight: 700;
color: #fa8c16;
}
.status-bar__btn {
padding: 6px 14px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
border: 1px solid rgba(255, 77, 79, 0.3);
background: var(--danger-light);
color: var(--danger);
cursor: pointer;
}
.status-bar__btn--disabled {
border: 1px solid #d9d9d9;
background: #f5f5f5;
color: #bfbfbf;
cursor: not-allowed;
}
/* 确认弹窗 */
.confirm-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 200;
justify-content: center;
align-items: center;
}
.confirm-overlay--visible { display: flex; }
.confirm-dialog {
width: 300px;
background: #fff;
border-radius: 20px;
padding: 24px 20px 18px;
text-align: center;
}
.confirm-dialog__title {
font-size: 17px;
font-weight: 700;
margin-bottom: 14px;
color: var(--text-primary);
}
.confirm-dialog__info {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 20px;
padding: 12px;
background: #f9fbfb;
border-radius: 12px;
border: 1px solid #f0f5f5;
}
.confirm-dialog__row {
display: flex;
justify-content: space-between;
font-size: 13px;
}
.confirm-dialog__label { color: var(--text-muted); }
.confirm-dialog__value { color: var(--text-primary); font-weight: 600; }
.confirm-dialog__actions {
display: flex;
gap: 10px;
}
.confirm-dialog__btn {
flex: 1;
height: 42px;
border-radius: 21px;
font-size: 14px;
font-weight: 600;
border: none;
cursor: pointer;
}
.confirm-dialog__btn--cancel {
background: #f5f7fa;
color: var(--text-secondary);
border: 1px solid #ebeff2;
}
.confirm-dialog__btn--confirm {
background: var(--danger);
color: #fff;
}
.toast {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0,0,0,0.76);
color: #fff;
padding: 14px 24px;
border-radius: 12px;
font-size: 14px;
z-index: 200;
text-align: center;
max-width: 280px;
line-height: 1.6;
display: none;
}
.toast--visible { display: block; }
.status-card {
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%);
border-radius: 24px;
padding: 22px 18px;
color: #fff;
box-shadow: 0 12px 28px rgba(19, 194, 194, 0.24);
}
.status-card__badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(255,255,255,0.18);
font-size: 12px;
font-weight: 600;
margin-bottom: 14px;
}
.status-card__title {
font-size: 24px;
font-weight: 700;
line-height: 1.25;
margin-bottom: 8px;
}
.status-card__desc {
font-size: 13px;
line-height: 1.7;
opacity: 0.92;
}
.summary-card,
.info-card {
background: #fff;
border-radius: 20px;
padding: 18px 16px;
margin-top: 14px;
margin-top: 14px;
box-shadow: var(--shadow);
border: 1px solid rgba(19, 194, 194, 0.06);
}
.section-title {
font-size: 16px;
font-weight: 700;
margin-bottom: 14px;
}
.summary-list,
.info-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.summary-item,
.info-item {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.summary-item__label,
.info-item__label {
font-size: 13px;
color: var(--text-muted);
flex-shrink: 0;
}
.summary-item__value,
.info-item__value {
font-size: 14px;
color: var(--text-primary);
font-weight: 600;
text-align: right;
line-height: 1.6;
}
.summary-item--fee {
margin-top: 2px;
padding: 14px;
border-radius: 18px;
background: linear-gradient(135deg, #f0fffe 0%, #f7fffd 100%);
border: 1px solid #d7f5f3;
align-items: center;
}
.summary-item--fee .summary-item__label {
color: var(--primary-dark);
font-weight: 600;
}
.summary-item--fee .summary-item__value {
color: var(--primary-dark);
font-size: 24px;
font-weight: 800;
}
.person-card {
margin-top: 14px;
background: linear-gradient(135deg, #ffffff 0%, #f8fffe 100%);
border-radius: 20px;
padding: 16px;
border: 1px solid #e4f7f5;
box-shadow: var(--shadow);
}
.person-card__header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.person-card__avatar {
width: 48px;
height: 48px;
border-radius: 16px;
background: linear-gradient(135deg, #13c2c2 0%, #5cdbd3 100%);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 700;
flex-shrink: 0;
}
.person-card__name {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 4px;
}
.person-card__meta {
font-size: 12px;
color: var(--text-muted);
}
.tips-card {
margin-top: 14px;
background: #fff;
border-radius: 20px;
padding: 16px;
border: 1px solid #eef5f5;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.04);
}
.tips-card__title {
font-size: 15px;
font-weight: 700;
margin-bottom: 10px;
}
.tips-card__item {
display: flex;
gap: 8px;
font-size: 13px;
line-height: 1.7;
color: var(--text-secondary);
margin-bottom: 8px;
}
.tips-card__item:last-child { margin-bottom: 0; }
.tips-card__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--primary);
margin-top: 8px;
flex-shrink: 0;
}
.qrcode-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.qrcode-wrap__img {
width: 160px;
height: 160px;
border-radius: 12px;
border: 1px solid #eef5f5;
cursor: pointer;
transition: transform 0.2s;
}
.qrcode-wrap__img:hover { transform: scale(1.04); }
.qrcode-wrap__hint {
font-size: 12px;
color: var(--text-muted);
}
/* 二维码放大遮罩 */
.qr-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 100;
justify-content: center;
align-items: center;
cursor: pointer;
}
.qr-overlay--visible { display: flex; }
.qr-overlay__img {
width: 260px;
height: 260px;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
/* 体检进度 */
.progress-card {
margin-top: 14px;
background: #fff;
border-radius: 20px;
padding: 18px 16px;
box-shadow: var(--shadow);
border: 1px solid rgba(19, 194, 194, 0.06);
}
.progress-card__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.progress-card__title {
font-size: 16px;
font-weight: 700;
}
.progress-card__ratio {
font-size: 13px;
color: var(--primary-dark);
font-weight: 600;
}
.progress-bar-lg {
height: 10px;
border-radius: 999px;
background: #eef3f3;
overflow: hidden;
margin-bottom: 16px;
}
.progress-bar-lg__fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #13c2c2 0%, #5cdbd3 100%);
}
.project-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 14px;
}
.project-tabs__btn {
height: 34px;
border: none;
border-radius: 12px;
background: #f5f7fa;
color: var(--text-secondary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.project-tabs__btn--active {
background: var(--primary-light);
color: var(--primary-dark);
box-shadow: inset 0 0 0 1px rgba(19, 194, 194, 0.16);
}
.project-list { display: flex; flex-direction: column; gap: 8px; }
.project-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 14px;
background: #f9fbfb;
border: 1px solid #f0f5f5;
}
.project-item__icon {
width: 28px;
height: 28px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 14px;
}
.project-item__icon--done {
background: var(--success-light);
color: var(--success-text);
}
.project-item__icon--pending {
background: #fff7e6;
color: #fa8c16;
}
.project-item__name {
flex: 1;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.project-item__status {
font-size: 12px;
font-weight: 600;
}
.project-item__status--done { color: var(--success-text); }
.project-item__status--pending { color: #fa8c16; }
.hidden { display: none !important; }
.bottom-actions {
position: absolute;
left: 16px;
right: 16px;
bottom: 18px;
display: flex;
gap: 12px;
padding-bottom: 16px;
}
.bottom-actions__btn {
flex: 1;
height: 48px;
border-radius: 24px;
font-size: 15px;
font-weight: 600;
border: none;
cursor: pointer;
}
.bottom-actions__btn--ghost {
background: var(--danger-light);
color: var(--danger);
border: 1px solid rgba(255, 77, 79, 0.2);
}
.bottom-actions__btn--primary {
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%);
color: #fff;
box-shadow: 0 10px 20px rgba(19, 194, 194, 0.22);
}
</style>
</head>
<body>
<div class="phone-shell">
<div class="phone-status-bar">
<span class="time">12:00</span>
<div class="icons">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<div class="nav-bar">
<!-- [交互] 点击返回至: my-appointments.html -->
<a class="nav-back" href="my-appointments.html"></a>
<span class="nav-title">体检预约详情</span>
</div>
<div class="scroll-content">
<!-- 预约状态栏 -->
<div class="status-bar">
<div class="status-bar__left">
<div class="status-bar__dot"></div>
<div class="status-bar__text">待体检</div>
</div>
<button class="status-bar__btn status-bar__btn--disabled" id="cancelBtn" disabled>取消预约</button>
</div>
<div class="summary-card">
<div class="section-title">常规体检</div>
<div class="summary-list">
<div class="summary-item">
<div class="summary-item__label">体检医院</div>
<div class="summary-item__value">宁夏宝石花医院</div>
</div>
<div class="summary-item">
<div class="summary-item__label">预约日期</div>
<div class="summary-item__value">2026-03-23</div>
</div>
<div class="summary-item">
<div class="summary-item__label">体检员工</div>
<div class="summary-item__value">张旭</div>
</div>
<div class="summary-item">
<div class="summary-item__label">单位部门</div>
<div class="summary-item__value">采油一厂·xx作业区</div>
</div>
</div>
</div>
<div class="person-card">
<div class="section-title">体检签到码</div>
<div class="qrcode-wrap">
<!-- [交互] 点击二维码放大展示 -->
<svg class="qrcode-wrap__img" onclick="document.getElementById('qrOverlay').classList.add('qr-overlay--visible')" viewBox="0 0 160 160" xmlns="http://www.w3.org/2000/svg">
<rect width="160" height="160" fill="#fff"/>
<!-- 左上定位块 -->
<rect x="12" y="12" width="40" height="40" rx="4" fill="#1a1a1a"/>
<rect x="18" y="18" width="28" height="28" rx="2" fill="#fff"/>
<rect x="24" y="24" width="16" height="16" rx="2" fill="#1a1a1a"/>
<!-- 右上定位块 -->
<rect x="108" y="12" width="40" height="40" rx="4" fill="#1a1a1a"/>
<rect x="114" y="18" width="28" height="28" rx="2" fill="#fff"/>
<rect x="120" y="24" width="16" height="16" rx="2" fill="#1a1a1a"/>
<!-- 左下定位块 -->
<rect x="12" y="108" width="40" height="40" rx="4" fill="#1a1a1a"/>
<rect x="18" y="114" width="28" height="28" rx="2" fill="#fff"/>
<rect x="24" y="120" width="16" height="16" rx="2" fill="#1a1a1a"/>
<!-- 数据区域模拟 -->
<rect x="60" y="12" width="8" height="8" fill="#1a1a1a"/><rect x="76" y="12" width="8" height="8" fill="#1a1a1a"/><rect x="92" y="12" width="8" height="8" fill="#1a1a1a"/>
<rect x="60" y="24" width="8" height="8" fill="#1a1a1a"/><rect x="84" y="24" width="8" height="8" fill="#1a1a1a"/>
<rect x="68" y="36" width="8" height="8" fill="#1a1a1a"/><rect x="92" y="36" width="8" height="8" fill="#1a1a1a"/>
<rect x="12" y="60" width="8" height="8" fill="#1a1a1a"/><rect x="28" y="60" width="8" height="8" fill="#1a1a1a"/><rect x="44" y="60" width="8" height="8" fill="#1a1a1a"/><rect x="68" y="60" width="8" height="8" fill="#1a1a1a"/><rect x="92" y="60" width="8" height="8" fill="#1a1a1a"/><rect x="108" y="60" width="8" height="8" fill="#1a1a1a"/><rect x="132" y="60" width="8" height="8" fill="#1a1a1a"/>
<rect x="20" y="72" width="8" height="8" fill="#1a1a1a"/><rect x="44" y="72" width="8" height="8" fill="#1a1a1a"/><rect x="60" y="72" width="8" height="8" fill="#1a1a1a"/><rect x="84" y="72" width="8" height="8" fill="#1a1a1a"/><rect x="108" y="72" width="8" height="8" fill="#1a1a1a"/><rect x="124" y="72" width="8" height="8" fill="#1a1a1a"/>
<rect x="12" y="84" width="8" height="8" fill="#1a1a1a"/><rect x="36" y="84" width="8" height="8" fill="#1a1a1a"/><rect x="60" y="84" width="8" height="8" fill="#1a1a1a"/><rect x="76" y="84" width="8" height="8" fill="#1a1a1a"/><rect x="100" y="84" width="8" height="8" fill="#1a1a1a"/><rect x="140" y="84" width="8" height="8" fill="#1a1a1a"/>
<rect x="28" y="96" width="8" height="8" fill="#1a1a1a"/><rect x="52" y="96" width="8" height="8" fill="#1a1a1a"/><rect x="68" y="96" width="8" height="8" fill="#1a1a1a"/><rect x="92" y="96" width="8" height="8" fill="#1a1a1a"/><rect x="116" y="96" width="8" height="8" fill="#1a1a1a"/><rect x="140" y="96" width="8" height="8" fill="#1a1a1a"/>
<rect x="60" y="108" width="8" height="8" fill="#1a1a1a"/><rect x="76" y="108" width="8" height="8" fill="#1a1a1a"/><rect x="100" y="108" width="8" height="8" fill="#1a1a1a"/><rect x="124" y="108" width="8" height="8" fill="#1a1a1a"/>
<rect x="60" y="120" width="8" height="8" fill="#1a1a1a"/><rect x="84" y="120" width="8" height="8" fill="#1a1a1a"/><rect x="108" y="120" width="8" height="8" fill="#1a1a1a"/><rect x="140" y="120" width="8" height="8" fill="#1a1a1a"/>
<rect x="60" y="132" width="8" height="8" fill="#1a1a1a"/><rect x="76" y="132" width="8" height="8" fill="#1a1a1a"/><rect x="92" y="132" width="8" height="8" fill="#1a1a1a"/><rect x="116" y="132" width="8" height="8" fill="#1a1a1a"/><rect x="140" y="132" width="8" height="8" fill="#1a1a1a"/>
<rect x="68" y="140" width="8" height="8" fill="#1a1a1a"/><rect x="100" y="140" width="8" height="8" fill="#1a1a1a"/><rect x="132" y="140" width="8" height="8" fill="#1a1a1a"/>
</svg>
<span class="qrcode-wrap__hint">点击二维码可放大查看</span>
</div>
</div>
<!-- 体检进度 -->
<div class="progress-card">
<div class="progress-card__header">
<div class="progress-card__title">体检进度</div>
<div class="progress-card__ratio" id="progressRatio">5 / 26 项</div>
</div>
<div class="progress-bar-lg"><div class="progress-bar-lg__fill" id="progressFill" style="width: 19.2%;"></div></div>
<!-- [交互] 点击切换已检/未检 Tab -->
<div class="project-tabs" id="projectTabs">
<button class="project-tabs__btn project-tabs__btn--active" type="button" id="tabDone" onclick="switchTab(0)">已检项目 (0)</button>
<button class="project-tabs__btn" type="button" id="tabPending" onclick="switchTab(1)">未检项目 (0)</button>
</div>
<!-- 已检项目列表(JS动态生成) -->
<div class="project-list" id="doneList"></div>
<!-- 未检项目列表(默认隐藏,JS动态生成) -->
<div class="project-list hidden" id="pendingList"></div>
</div>
</div>
</div>
<!-- <div class="bottom-actions">-->
<!-- &lt;!&ndash; [交互] 点击跳转至: my-appointments.html &ndash;&gt;-->
<!-- <button class="bottom-actions__btn bottom-actions__btn&#45;&#45;ghost" onclick="window.location.href='my-appointments.html'">取消预约</button>-->
<!-- &lt;!&ndash; [交互] 点击跳转至: hospital-checkin.html &ndash;&gt;-->
<!-- <button class="bottom-actions__btn bottom-actions__btn&#45;&#45;primary" onclick="window.location.href='hospital-checkin.html'">到院体检</button>-->
<!-- </div>-->
<!-- </div>-->
<!-- 二维码放大遮罩 -->
<div class="qr-overlay" id="qrOverlay" onclick="this.classList.remove('qr-overlay--visible')">
<svg class="qr-overlay__img" viewBox="0 0 160 160" xmlns="http://www.w3.org/2000/svg">
<rect width="160" height="160" fill="#fff"/>
<rect x="12" y="12" width="40" height="40" rx="4" fill="#1a1a1a"/><rect x="18" y="18" width="28" height="28" rx="2" fill="#fff"/><rect x="24" y="24" width="16" height="16" rx="2" fill="#1a1a1a"/>
<rect x="108" y="12" width="40" height="40" rx="4" fill="#1a1a1a"/><rect x="114" y="18" width="28" height="28" rx="2" fill="#fff"/><rect x="120" y="24" width="16" height="16" rx="2" fill="#1a1a1a"/>
<rect x="12" y="108" width="40" height="40" rx="4" fill="#1a1a1a"/><rect x="18" y="114" width="28" height="28" rx="2" fill="#fff"/><rect x="24" y="120" width="16" height="16" rx="2" fill="#1a1a1a"/>
<rect x="60" y="12" width="8" height="8" fill="#1a1a1a"/><rect x="76" y="12" width="8" height="8" fill="#1a1a1a"/><rect x="92" y="12" width="8" height="8" fill="#1a1a1a"/>
<rect x="60" y="24" width="8" height="8" fill="#1a1a1a"/><rect x="84" y="24" width="8" height="8" fill="#1a1a1a"/>
<rect x="68" y="36" width="8" height="8" fill="#1a1a1a"/><rect x="92" y="36" width="8" height="8" fill="#1a1a1a"/>
<rect x="12" y="60" width="8" height="8" fill="#1a1a1a"/><rect x="28" y="60" width="8" height="8" fill="#1a1a1a"/><rect x="44" y="60" width="8" height="8" fill="#1a1a1a"/><rect x="68" y="60" width="8" height="8" fill="#1a1a1a"/><rect x="92" y="60" width="8" height="8" fill="#1a1a1a"/><rect x="108" y="60" width="8" height="8" fill="#1a1a1a"/><rect x="132" y="60" width="8" height="8" fill="#1a1a1a"/>
<rect x="20" y="72" width="8" height="8" fill="#1a1a1a"/><rect x="44" y="72" width="8" height="8" fill="#1a1a1a"/><rect x="60" y="72" width="8" height="8" fill="#1a1a1a"/><rect x="84" y="72" width="8" height="8" fill="#1a1a1a"/><rect x="108" y="72" width="8" height="8" fill="#1a1a1a"/><rect x="124" y="72" width="8" height="8" fill="#1a1a1a"/>
<rect x="12" y="84" width="8" height="8" fill="#1a1a1a"/><rect x="36" y="84" width="8" height="8" fill="#1a1a1a"/><rect x="60" y="84" width="8" height="8" fill="#1a1a1a"/><rect x="76" y="84" width="8" height="8" fill="#1a1a1a"/><rect x="100" y="84" width="8" height="8" fill="#1a1a1a"/><rect x="140" y="84" width="8" height="8" fill="#1a1a1a"/>
<rect x="28" y="96" width="8" height="8" fill="#1a1a1a"/><rect x="52" y="96" width="8" height="8" fill="#1a1a1a"/><rect x="68" y="96" width="8" height="8" fill="#1a1a1a"/><rect x="92" y="96" width="8" height="8" fill="#1a1a1a"/><rect x="116" y="96" width="8" height="8" fill="#1a1a1a"/><rect x="140" y="96" width="8" height="8" fill="#1a1a1a"/>
<rect x="60" y="108" width="8" height="8" fill="#1a1a1a"/><rect x="76" y="108" width="8" height="8" fill="#1a1a1a"/><rect x="100" y="108" width="8" height="8" fill="#1a1a1a"/><rect x="124" y="108" width="8" height="8" fill="#1a1a1a"/>
<rect x="60" y="120" width="8" height="8" fill="#1a1a1a"/><rect x="84" y="120" width="8" height="8" fill="#1a1a1a"/><rect x="108" y="120" width="8" height="8" fill="#1a1a1a"/><rect x="140" y="120" width="8" height="8" fill="#1a1a1a"/>
<rect x="60" y="132" width="8" height="8" fill="#1a1a1a"/><rect x="76" y="132" width="8" height="8" fill="#1a1a1a"/><rect x="92" y="132" width="8" height="8" fill="#1a1a1a"/><rect x="116" y="132" width="8" height="8" fill="#1a1a1a"/><rect x="140" y="132" width="8" height="8" fill="#1a1a1a"/>
<rect x="68" y="140" width="8" height="8" fill="#1a1a1a"/><rect x="100" y="140" width="8" height="8" fill="#1a1a1a"/><rect x="132" y="140" width="8" height="8" fill="#1a1a1a"/>
</svg>
</div>
<script>
function switchTab(idx) {
var btns = document.querySelectorAll('.project-tabs__btn');
btns.forEach(function(b) { b.classList.remove('project-tabs__btn--active'); });
btns[idx].classList.add('project-tabs__btn--active');
document.getElementById('doneList').classList.toggle('hidden', idx !== 0);
document.getElementById('pendingList').classList.toggle('hidden', idx !== 1);
}
function showToast(msg) {
var t = document.getElementById('cancelToast');
t.textContent = msg;
t.classList.add('toast--visible');
setTimeout(function() { t.classList.remove('toast--visible'); }, 2000);
}
// 读取 URL 参数
var params = new URLSearchParams(window.location.search);
var doneCount = parseInt(params.get('done') || '5', 10);
var totalCount = parseInt(params.get('total') || '26', 10);
var hospital = params.get('hospital') || '宁夏宝石花医院';
var date = params.get('date') || '2026-03-23';
// 动态更新体检进度
var pct = totalCount > 0 ? Math.round(doneCount / totalCount * 1000) / 10 : 0;
document.getElementById('progressRatio').textContent = doneCount + ' / ' + totalCount + ' 项';
document.getElementById('progressFill').style.width = pct + '%';
// 全部体检项目池(按常规体检顺序)
var allProjects = [
'一般检查', '内科', '外科', '眼科检查', '耳鼻喉科', '口腔科',
'血常规', '尿常规', '肝功能', '肾功能', '血脂四项', '空腹血糖',
'甲状腺功能', '肿瘤标志物', '心电图', '胸部DR', '腹部彩超',
'甲状腺彩超', '泌尿系彩超', '心脏彩超', '颈动脉彩超',
'骨密度检测', '肺功能', '幽门螺杆菌', '中医体质辨识', '总检报告'
];
// 取前 totalCount 个项目,前 doneCount 个为已检
var projects = allProjects.slice(0, totalCount);
var doneProjects = projects.slice(0, doneCount);
var pendingProjects = projects.slice(doneCount);
function renderItem(name, isDone) {
return '<div class="project-item">' +
'<div class="project-item__icon project-item__icon--' + (isDone ? 'done' : 'pending') + '">' +
(isDone ? '&#10003;' : '&#9679;') + '</div>' +
'<div class="project-item__name">' + name + '</div>' +
'<div class="project-item__status project-item__status--' + (isDone ? 'done' : 'pending') + '">' +
(isDone ? '已完成' : '未检') + '</div></div>';
}
document.getElementById('doneList').innerHTML = doneProjects.map(function(n) { return renderItem(n, true); }).join('');
document.getElementById('pendingList').innerHTML = pendingProjects.map(function(n) { return renderItem(n, false); }).join('');
document.getElementById('tabDone').textContent = '已检项目 (' + doneCount + ')';
document.getElementById('tabPending').textContent = '未检项目 (' + pendingProjects.length + ')';
// done=0 时默认显示未检 Tab
if (doneCount === 0) {
switchTab(1);
}
// 根据进度控制取消按钮状态
var cancelBtn = document.getElementById('cancelBtn');
if (doneCount > 0) {
// 已有体检进度 → 按钮禁用,点击弹 toast
cancelBtn.classList.add('status-bar__btn--disabled');
cancelBtn.disabled = false;
cancelBtn.onclick = function() {
showToast('您当前已体检了部分项目,无法取消预约');
};
} else {
// 无体检进度 → 按钮可点击,弹二次确认
cancelBtn.classList.remove('status-bar__btn--disabled');
cancelBtn.disabled = false;
cancelBtn.onclick = function() {
document.getElementById('confirmHospital').textContent = hospital;
document.getElementById('confirmDate').textContent = date;
document.getElementById('confirmOverlay').classList.add('confirm-overlay--visible');
};
}
</script>
<div class="toast" id="cancelToast"></div>
<!-- 取消预约二次确认弹窗 -->
<div class="confirm-overlay" id="confirmOverlay">
<div class="confirm-dialog">
<div class="confirm-dialog__title">是否确认取消预约</div>
<div class="confirm-dialog__info">
<div class="confirm-dialog__row">
<span class="confirm-dialog__label">体检医院</span>
<span class="confirm-dialog__value" id="confirmHospital">宁夏宝石花医院</span>
</div>
<div class="confirm-dialog__row">
<span class="confirm-dialog__label">预约日期</span>
<span class="confirm-dialog__value" id="confirmDate">2026-03-23</span>
</div>
</div>
<div class="confirm-dialog__actions">
<button class="confirm-dialog__btn confirm-dialog__btn--cancel" onclick="document.getElementById('confirmOverlay').classList.remove('confirm-overlay--visible')">再想想</button>
<button class="confirm-dialog__btn confirm-dialog__btn--confirm" onclick="window.location.href='my-appointments.html'">确认取消</button>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,163 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>我的预约</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: #13c2c2;
--primary-dark: #0f9b9b;
--primary-light: #e6fffb;
--success-bg: #f6ffed;
--success-text: #52c41a;
--warning-bg: #fff7e6;
--warning-text: #fa8c16;
--danger-bg: #fff1f0;
--danger-text: #ff4d4f;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--text-muted: #8c8c8c;
--bg-page: #f0f2f5;
--bg-content: #f7f9fb;
--border: #e8f3f3;
--shadow: 0 10px 24px rgba(19, 194, 194, 0.06);
}
body {
background: var(--bg-page);
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
color: var(--text-primary);
}
.phone-shell {
width: 390px; min-height: 844px; background: var(--bg-content);
border-radius: 44px; box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0;
overflow: hidden; position: relative; display: flex; flex-direction: column;
}
.phone-status-bar {
height: 44px; background: #fff; display: flex; align-items: center;
justify-content: space-between; padding: 0 24px; font-size: 12px; font-weight: 600; flex-shrink: 0;
}
.phone-status-bar .time { font-size: 15px; font-weight: 700; }
.phone-status-bar .icons { display: flex; gap: 6px; align-items: center; }
.nav-bar {
height: 44px; display: flex; align-items: center; padding: 0 16px;
background: #fff; border-bottom: 1px solid #f0f0f0; flex-shrink: 0; position: relative;
}
.nav-back { font-size: 18px; color: var(--text-primary); cursor: pointer; line-height: 1; text-decoration: none; }
.nav-title { position: absolute; left: 50%; transform: translateX(-50%); font-size: 17px; font-weight: 600; color: var(--text-primary); }
.scroll-content {
flex: 1; overflow-y: auto;
background: linear-gradient(180deg, #f0fbfb 0%, #f7f9fb 140px, #f7f9fb 100%);
padding: 14px 16px 20px;
}
.scroll-content::-webkit-scrollbar { display: none; }
.tabs {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; padding: 6px;
background: rgba(255,255,255,0.92); border-radius: 18px;
box-shadow: 0 8px 20px rgba(19, 194, 194, 0.08);
position: sticky; top: 0; z-index: 5; backdrop-filter: blur(10px);
}
.tabs__item {
height: 36px; border: none; border-radius: 14px; background: transparent;
color: var(--text-secondary); font-size: 13px; font-weight: 600; cursor: pointer;
text-decoration: none; display: flex; align-items: center; justify-content: center;
}
.tabs__item--active {
background: var(--primary-light); color: var(--primary-dark);
box-shadow: inset 0 0 0 1px rgba(19, 194, 194, 0.16);
}
.summary-bar {
margin: 14px 0 12px; padding: 16px; border-radius: 20px;
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%);
color: #fff; box-shadow: 0 12px 24px rgba(19, 194, 194, 0.2);
}
.summary-bar__title { font-size: 18px; font-weight: 700; margin-bottom: 6px; }
.summary-bar__desc { font-size: 13px; line-height: 1.7; opacity: 0.92; }
.appointment-list { display: flex; flex-direction: column; gap: 12px; }
.appointment-card {
background: #fff; border-radius: 20px; padding: 16px;
border: 1px solid rgba(19, 194, 194, 0.06); box-shadow: var(--shadow);
}
.appointment-card__top { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 12px; }
.appointment-card__title { font-size: 16px; font-weight: 700; color: var(--text-primary); }
.appointment-card__status { flex-shrink: 0; padding: 5px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.appointment-card__status--done { background: var(--success-bg); color: var(--success-text); }
.appointment-card__meta { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 12px; margin-bottom: 12px; }
.meta-item { background: #f9fbfb; border-radius: 14px; padding: 10px 12px; border: 1px solid #f0f5f5; }
.meta-item__label { font-size: 11px; color: var(--text-muted); margin-bottom: 5px; }
.meta-item__value { font-size: 13px; color: var(--text-primary); font-weight: 600; line-height: 1.5; }
.appointment-card__progress { margin-bottom: 14px; }
.progress-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 12px; color: var(--text-secondary); }
.progress-bar { height: 8px; border-radius: 999px; background: #eef3f3; overflow: hidden; }
.progress-bar__fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, #13c2c2 0%, #5cdbd3 100%); }
.appointment-card__actions { display: flex; gap: 10px; }
.appointment-card__btn {
flex: 1; height: 40px; border-radius: 20px; font-size: 14px; font-weight: 600; border: none; cursor: pointer;
}
.appointment-card__btn--ghost { background: #f5f7fa; color: var(--text-secondary); border: 1px solid #ebeff2; }
</style>
</head>
<body>
<div class="phone-shell">
<div class="phone-status-bar">
<span class="time">12:00</span>
<div class="icons">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<div class="nav-bar">
<a class="nav-back" href="index.html">&#8249;</a>
<span class="nav-title">我的预约</span>
</div>
<div class="scroll-content">
<!-- [交互] 点击 Tab 跳转对应页面 -->
<div class="tabs">
<a class="tabs__item" href="my-appointments-all.html">全部</a>
<a class="tabs__item" href="my-appointments.html">待体检</a>
<a class="tabs__item tabs__item--active" href="my-appointments-done.html">已完成</a>
<a class="tabs__item" href="my-appointments-cancelled.html">已取消</a>
</div>
<div class="appointment-list">
<a class="appointment-card" href="my-appointments-detail-done.html" style="text-decoration:none;color:inherit;">
<div class="appointment-card__top">
<div class="appointment-card__title">常规体检</div>
<div class="appointment-card__status appointment-card__status--done">已完成</div>
</div>
<div class="appointment-card__meta">
<div class="meta-item"><div class="meta-item__label">体检医院</div><div class="meta-item__value">宁夏宝石花医院</div></div>
<div class="meta-item"><div class="meta-item__label">预约日期</div><div class="meta-item__value">2026-03-23</div></div>
</div>
<div class="appointment-card__progress">
<div class="progress-head"><span>完成进度</span><span>26/26</span></div>
<div class="progress-bar"><div class="progress-bar__fill" style="width: 100%;"></div></div>
</div>
</a>
<a class="appointment-card" href="my-appointments-detail-done.html" style="text-decoration:none;color:inherit;">
<div class="appointment-card__top">
<div class="appointment-card__title">胃肠镜体检</div>
<div class="appointment-card__status appointment-card__status--done">已完成</div>
</div>
<div class="appointment-card__meta">
<div class="meta-item"><div class="meta-item__label">体检医院</div><div class="meta-item__value">宁夏宝石花医院</div></div>
<div class="meta-item"><div class="meta-item__label">预约日期</div><div class="meta-item__value">2026-02-18</div></div>
</div>
<div class="appointment-card__progress">
<div class="progress-head"><span>完成进度</span><span>2/2</span></div>
<div class="progress-bar"><div class="progress-bar__fill" style="width: 100%;"></div></div>
</div>
</a>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,340 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>我的预约</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: #13c2c2;
--primary-dark: #0f9b9b;
--primary-light: #e6fffb;
--success-bg: #f6ffed;
--success-text: #52c41a;
--warning-bg: #fff7e6;
--warning-text: #fa8c16;
--danger-bg: #fff1f0;
--danger-text: #ff4d4f;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--text-muted: #8c8c8c;
--bg-page: #f0f2f5;
--bg-content: #f7f9fb;
--border: #e8f3f3;
--shadow: 0 10px 24px rgba(19, 194, 194, 0.06);
}
body {
background: var(--bg-page);
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
color: var(--text-primary);
}
.phone-shell {
width: 390px;
min-height: 844px;
background: var(--bg-content);
border-radius: 44px;
box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
}
.phone-status-bar {
height: 44px;
background: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.phone-status-bar .time { font-size: 15px; font-weight: 700; }
.phone-status-bar .icons { display: flex; gap: 6px; align-items: center; }
.nav-bar {
height: 44px;
display: flex;
align-items: center;
padding: 0 16px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
position: relative;
}
.nav-back {
font-size: 18px;
color: var(--text-primary);
cursor: pointer;
line-height: 1;
text-decoration: none;
}
.nav-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 17px;
font-weight: 600;
color: var(--text-primary);
}
.scroll-content {
flex: 1;
overflow-y: auto;
background: linear-gradient(180deg, #f0fbfb 0%, #f7f9fb 140px, #f7f9fb 100%);
padding: 14px 16px 20px;
}
.scroll-content::-webkit-scrollbar { display: none; }
.tabs {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
padding: 6px;
background: rgba(255,255,255,0.92);
border-radius: 18px;
box-shadow: 0 8px 20px rgba(19, 194, 194, 0.08);
position: sticky;
top: 0;
z-index: 5;
backdrop-filter: blur(10px);
}
.tabs__item {
height: 36px;
border: none;
border-radius: 14px;
background: transparent;
color: var(--text-secondary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
display: flex;
align-items: center;
justify-content: center;
}
.tabs__item--active {
background: var(--primary-light);
color: var(--primary-dark);
box-shadow: inset 0 0 0 1px rgba(19, 194, 194, 0.16);
}
.summary-bar {
margin: 14px 0 12px;
padding: 16px;
border-radius: 20px;
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%);
color: #fff;
box-shadow: 0 12px 24px rgba(19, 194, 194, 0.2);
}
.summary-bar__title {
font-size: 18px;
font-weight: 700;
margin-bottom: 6px;
}
.summary-bar__desc {
font-size: 13px;
line-height: 1.7;
opacity: 0.92;
}
.appointment-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.appointment-card {
background: #fff;
border-radius: 20px;
padding: 16px;
border: 1px solid rgba(19, 194, 194, 0.06);
box-shadow: var(--shadow);
}
.appointment-card__top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.appointment-card__title {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
.appointment-card__status {
flex-shrink: 0;
padding: 5px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.appointment-card__status--pending {
background: var(--warning-bg);
color: var(--warning-text);
}
.appointment-card__status--done {
background: var(--success-bg);
color: var(--success-text);
}
.appointment-card__status--cancelled {
background: var(--danger-bg);
color: var(--danger-text);
}
.appointment-card__meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 12px;
margin-bottom: 12px;
}
.meta-item {
background: #f9fbfb;
border-radius: 14px;
padding: 10px 12px;
border: 1px solid #f0f5f5;
}
.meta-item__label {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 5px;
}
.meta-item__value {
font-size: 13px;
color: var(--text-primary);
font-weight: 600;
line-height: 1.5;
}
.appointment-card__progress {
margin-bottom: 14px;
}
.progress-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 12px;
color: var(--text-secondary);
}
.progress-bar {
height: 8px;
border-radius: 999px;
background: #eef3f3;
overflow: hidden;
}
.progress-bar__fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #13c2c2 0%, #5cdbd3 100%);
}
.appointment-card__actions {
display: flex;
gap: 10px;
}
.appointment-card__btn {
flex: 1;
height: 40px;
border-radius: 20px;
font-size: 14px;
font-weight: 600;
border: none;
cursor: pointer;
}
.appointment-card__btn--ghost {
background: #f5f7fa;
color: var(--text-secondary);
border: 1px solid #ebeff2;
}
.appointment-card__btn--primary {
background: linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%);
color: #fff;
box-shadow: 0 8px 20px rgba(19, 194, 194, 0.18);
}
.appointment-card[data-status="done"],
.appointment-card[data-status="cancelled"] {
opacity: 0.94;
}
</style>
</head>
<body>
<div class="phone-shell">
<div class="phone-status-bar">
<span class="time">12:00</span>
<div class="icons">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<div class="nav-bar">
<!-- [交互] 点击返回至: index.html -->
<a class="nav-back" href="index.html"></a>
<span class="nav-title">我的预约</span>
</div>
<div class="scroll-content">
<!-- [交互] 点击 Tab 跳转对应页面 -->
<div class="tabs">
<a class="tabs__item" href="my-appointments-all.html">全部</a>
<a class="tabs__item tabs__item--active" href="my-appointments.html">待体检</a>
<a class="tabs__item" href="my-appointments-done.html">已完成</a>
<a class="tabs__item" href="my-appointments-cancelled.html">已取消</a>
</div>
<div class="appointment-list">
<!-- [交互] 点击卡片跳转至详情页,URL携带进度/医院/日期参数 -->
<a class="appointment-card" data-status="pending" href="my-appointments-detail.html?done=5&total=26&hospital=宁夏宝石花医院&date=2026-03-23" style="text-decoration:none;color:inherit;">
<div class="appointment-card__top">
<div class="appointment-card__title">常规体检</div>
<div class="appointment-card__status appointment-card__status--pending">待体检</div>
</div>
<div class="appointment-card__meta">
<div class="meta-item">
<div class="meta-item__label">体检医院</div>
<div class="meta-item__value">宁夏宝石花医院</div>
</div>
<div class="meta-item">
<div class="meta-item__label">预约日期</div>
<div class="meta-item__value">2026-03-23</div>
</div>
</div>
<div class="appointment-card__progress">
<div class="progress-head">
<span>体检进度</span>
<span>5/26</span>
</div>
<div class="progress-bar"><div class="progress-bar__fill" style="width: 19.2%;"></div></div>
</div>
</a>
<a class="appointment-card" data-status="pending" href="my-appointments-detail.html?done=0&total=26&hospital=宁夏宝石花医院&date=2026-03-23" style="text-decoration:none;color:inherit;">
<div class="appointment-card__top">
<div class="appointment-card__title">常规体检</div>
<div class="appointment-card__status appointment-card__status--pending">待体检</div>
</div>
<div class="appointment-card__meta">
<div class="meta-item">
<div class="meta-item__label">体检医院</div>
<div class="meta-item__value">宁夏宝石花医院</div>
</div>
<div class="meta-item">
<div class="meta-item__label">预约日期</div>
<div class="meta-item__value">2026-03-23</div>
</div>
</div>
<div class="appointment-card__progress">
<div class="progress-head">
<span>体检进度</span>
<span>0/26</span>
</div>
<div class="progress-bar"><div class="progress-bar__fill" style="width: 0%;"></div></div>
</div>
</a>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,215 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>项目详情</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: #13c2c2; --primary-dark: #0f9b9b; --primary-light: #e6fffb;
--text-primary: #1a1a1a; --text-secondary: #666666; --text-muted: #8c8c8c;
--bg-page: #f0f2f5; --bg-content: #f7f9fb;
--shadow: 0 10px 30px rgba(19, 194, 194, 0.08);
}
body {
background: var(--bg-page); display: flex; justify-content: center; align-items: flex-start;
min-height: 100vh; padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
color: var(--text-primary);
}
.phone-shell {
width: 390px; min-height: 844px; background: var(--bg-content);
border-radius: 44px; box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0;
overflow: hidden; position: relative; display: flex; flex-direction: column;
}
.phone-status-bar {
height: 44px; background: #fff; display: flex; align-items: center;
justify-content: space-between; padding: 0 24px; font-size: 12px; font-weight: 600; flex-shrink: 0;
}
.phone-status-bar .time { font-size: 15px; font-weight: 700; }
.phone-status-bar .icons { display: flex; gap: 6px; align-items: center; }
.nav-bar {
height: 44px; display: flex; align-items: center; padding: 0 16px;
background: #fff; border-bottom: 1px solid #f0f0f0; flex-shrink: 0; position: relative;
}
.nav-back { font-size: 18px; color: var(--text-primary); cursor: pointer; line-height: 1; text-decoration: none; }
.nav-title { position: absolute; left: 50%; transform: translateX(-50%); font-size: 17px; font-weight: 600; }
.scroll-content {
flex: 1; overflow-y: auto; background: #f7f9fb;
}
.scroll-content::-webkit-scrollbar { display: none; }
/* 顶部项目图片 */
.detail-hero {
width: 100%; height: 220px; background: linear-gradient(135deg, #e0f7fa 0%, #b2dfdb 50%, #80cbc4 100%);
display: flex; align-items: center; justify-content: center; position: relative;
}
.detail-hero__icon {
width: 80px; height: 80px; border-radius: 24px;
background: rgba(255,255,255,0.85); display: flex; align-items: center; justify-content: center;
box-shadow: 0 8px 24px rgba(0,0,0,0.08);
}
.detail-hero__icon svg { width: 40px; height: 40px; color: var(--primary-dark); }
/* 内容区 */
.detail-body { padding: 20px 16px 32px; }
.detail-name {
font-size: 22px; font-weight: 800; color: var(--text-primary); margin-bottom: 6px;
}
.detail-meta {
display: flex; gap: 10px; align-items: center; margin-bottom: 18px;
}
.detail-badge {
display: inline-flex; align-items: center; height: 24px; padding: 0 12px;
border-radius: 12px; font-size: 12px; font-weight: 600;
}
.detail-badge--normal { background: #f5f5f5; color: var(--text-muted); }
.detail-badge--key { background: #fff1f0; color: #ff4d4f; }
.detail-price { font-size: 18px; font-weight: 800; color: var(--primary-dark); }
/* 卡片通用 */
.detail-card {
background: #fff; border-radius: 20px; padding: 18px 16px; margin-bottom: 14px;
box-shadow: var(--shadow); border: 1px solid rgba(19,194,194,0.06);
}
.detail-card__title {
font-size: 16px; font-weight: 700; color: var(--text-primary); margin-bottom: 12px;
display: flex; align-items: center; gap: 8px;
}
.detail-card__title-dot {
width: 4px; height: 18px; border-radius: 2px; background: var(--primary);
}
.detail-card__text {
font-size: 14px; color: var(--text-secondary); line-height: 1.8;
}
/* 视频播放器占位 */
.video-section { margin-bottom: 16px; }
.video-section:last-child { margin-bottom: 0; }
.video-section__label {
font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 10px;
display: flex; align-items: center; gap: 6px;
}
.video-section__label-icon { color: var(--primary); }
.video-player {
width: 100%; height: 180px; border-radius: 16px; overflow: hidden;
background: #1a1a1a; position: relative; display: flex; align-items: center; justify-content: center;
}
.video-player__poster {
position: absolute; inset: 0;
background: linear-gradient(135deg, #2a2a2a 0%, #3a3a3a 100%);
}
.video-player__play {
width: 56px; height: 56px; border-radius: 50%; border: none; cursor: pointer;
background: rgba(255,255,255,0.2); backdrop-filter: blur(8px);
display: flex; align-items: center; justify-content: center; position: relative; z-index: 1;
transition: transform 0.2s, background 0.2s;
}
.video-player__play:hover { transform: scale(1.08); background: rgba(255,255,255,0.3); }
.video-player__play svg { color: #fff; }
.video-player__duration {
position: absolute; bottom: 10px; right: 12px; font-size: 12px; color: rgba(255,255,255,0.7);
z-index: 1;
}
</style>
</head>
<body>
<div class="phone-shell">
<div class="phone-status-bar">
<span class="time">12:00</span>
<div class="icons">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<!-- [交互] 返回上一页 -->
<div class="nav-bar">
<a class="nav-back" onclick="history.back()">&#8249;</a>
<span class="nav-title">项目详情</span>
</div>
<div class="scroll-content" id="scrollContent"></div>
</div>
<script>
const projects = {
general: { name: '一般检查', desc: '身高、体重、血压、视力', price: 120, level: 'normal',
intro: '一般检查是健康体检的基础项目,通过测量身高、体重、体质指数(BMI)、血压、脉搏及视力等基本生理指标,初步评估受检者的整体健康状况,为后续专项检查提供参考依据。',
videos: { before: '03:25', after: '02:18', faq: '04:12' } },
blood: { name: '血常规', desc: '血红蛋白、白细胞、血小板等', price: 80, level: 'normal',
intro: '血常规检查通过采集静脉血样本,分析红细胞、白细胞、血小板等血液成分的数量和形态,是筛查贫血、感染、血液系统疾病等的重要基础检查项目。',
videos: { before: '02:50', after: '03:05', faq: '03:40' } },
urine: { name: '尿常规', desc: '尿蛋白、尿糖、尿潜血等', price: 45, level: 'normal',
intro: '尿常规检查通过分析尿液中的蛋白质、糖分、潜血、白细胞等成分,辅助筛查泌尿系统疾病、糖尿病、肾脏疾病等,是临床常用的基础检验项目。',
videos: { before: '02:15', after: '01:58', faq: '03:10' } },
liver: { name: '肝功能', desc: 'ALT、AST、总胆红素等', price: 160, level: 'key',
intro: '肝功能检查通过检测血清中谷丙转氨酶(ALT)、谷草转氨酶(AST)、总胆红素、白蛋白等指标,评估肝脏的代谢、合成和解毒功能,是肝脏疾病筛查的核心项目。',
videos: { before: '03:45', after: '02:30', faq: '04:20' } },
kidney: { name: '肾功能', desc: '肌酐、尿素氮、尿酸等', price: 110, level: 'key',
intro: '肾功能检查通过检测血清肌酐、尿素氮、尿酸等指标,评估肾脏的滤过和排泄功能,早期发现肾脏损伤和代谢异常,对高血压、糖尿病患者尤为重要。',
videos: { before: '03:10', after: '02:45', faq: '03:55' } },
lipid: { name: '血脂四项', desc: '总胆固醇、甘油三酯、HDL、LDL', price: 90, level: 'normal',
intro: '血脂四项检查包括总胆固醇、甘油三酯、高密度脂蛋白胆固醇(HDL-C)和低密度脂蛋白胆固醇(LDL-C),是评估心脑血管疾病风险的关键指标。',
videos: { before: '02:40', after: '03:15', faq: '04:00' } },
glucose: { name: '空腹血糖', desc: '空腹静脉血糖检测', price: 40, level: 'normal',
intro: '空腹血糖检测要求受检者空腹8-12小时后采集静脉血,测定血液中葡萄糖浓度,是筛查糖尿病和糖耐量异常的首选检查方法。',
videos: { before: '02:20', after: '01:50', faq: '03:30' } },
ecg: { name: '心电图', desc: '十二导联心电图检查', price: 75, level: 'key',
intro: '十二导联心电图通过记录心脏电活动,检测心律失常、心肌缺血、心肌梗死等心脏疾病,是心血管系统筛查的基础且不可替代的检查手段。',
videos: { before: '03:00', after: '02:25', faq: '04:35' } },
xray: { name: '胸部X光', desc: '胸部正位X线摄影', price: 140, level: 'normal',
intro: '胸部X光检查通过X射线成像技术,观察肺部、心脏、纵隔及胸廓骨骼结构,筛查肺炎、肺结核、肺部肿瘤、心脏增大等胸部疾病。',
videos: { before: '02:55', after: '02:10', faq: '03:45' } },
ultrasound: { name: '腹部B超', desc: '肝胆脾胰肾超声检查', price: 180, level: 'key',
intro: '腹部B超利用超声波成像技术,对肝脏、胆囊、脾脏、胰腺和双肾进行无创检查,可发现结石、囊肿、肿瘤等器质性病变,是腹部脏器筛查的首选方法。',
videos: { before: '03:30', after: '02:00', faq: '04:10' } },
thyroid: { name: '甲状腺彩超', desc: '甲状腺结构与结节筛查', price: 160, level: 'key',
intro: '甲状腺彩超通过高频超声探头检查甲状腺的大小、形态、内部回声及血流情况,是筛查甲状腺结节、甲状腺肿大、甲状腺炎等疾病的重要影像学手段。',
videos: { before: '02:35', after: '01:45', faq: '03:20' } },
tumor: { name: '肿瘤标志物', desc: '基础肿瘤风险筛查', price: 220, level: 'key',
intro: '肿瘤标志物检查通过检测血清中特定蛋白质或代谢产物的浓度,辅助筛查常见恶性肿瘤风险。本项目包含AFP、CEA、CA199等基础肿瘤标志物组合。',
videos: { before: '04:00', after: '03:20', faq: '05:15' } }
};
const params = new URLSearchParams(window.location.search);
const projectId = params.get('id') || 'general';
const p = projects[projectId] || projects.general;
const playIcon = `<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>`;
const videoIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>`;
function renderVideo(label, duration) {
return `<div class="video-section">
<div class="video-section__label"><span class="video-section__label-icon">${videoIcon}</span>${label}</div>
<div class="video-player">
<div class="video-player__poster"></div>
<button class="video-player__play">${playIcon}</button>
<span class="video-player__duration">${duration}</span>
</div>
</div>`;
}
document.getElementById('scrollContent').innerHTML = `
<div class="detail-hero">
<div class="detail-hero__icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
</div>
</div>
<div class="detail-body">
<div class="detail-name">${p.name}</div>
<div class="detail-meta">
<span class="detail-badge detail-badge--${p.level === 'key' ? 'key' : 'normal'}">${p.level === 'key' ? '重点项目' : '一般项目'}</span>
<span class="detail-price">¥${p.price.toFixed(2)}</span>
</div>
<div class="detail-card">
<div class="detail-card__title"><span class="detail-card__title-dot"></span>项目介绍</div>
<div class="detail-card__text">${p.intro}</div>
</div>
<div class="detail-card">
<div class="detail-card__title"><span class="detail-card__title-dot"></span>体检说明</div>
${renderVideo('检前说明', p.videos.before)}
${renderVideo('检后说明', p.videos.after)}
${renderVideo('问题说明', p.videos.faq)}
</div>
</div>
`;
</script>
</body>
</html>
@@ -0,0 +1,742 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>体检报告查询</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--primary: #20c7c7;
--primary-dark: #119b9b;
--primary-light: #e8fbfb;
--border: #cfeeee;
--line: #dff3f3;
--bg-page: #f0f2f5;
--bg-content: #f6fbfb;
--text-primary: #1a1a1a;
--text-secondary: #666;
--text-muted: #8c8c8c;
--danger: #ff4d4f;
--danger-light: #fff1f0;
--warning: #fa8c16;
--success: #52c41a;
--paper-shadow: 0 8px 24px rgba(17, 155, 155, 0.08);
}
body {
background: var(--bg-page);
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
color: var(--text-primary);
}
.phone-shell {
width: 390px;
min-height: 844px;
background: var(--bg-content);
border-radius: 44px;
box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
}
.phone-status-bar {
height: 44px;
background: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.phone-status-bar .time { font-size: 15px; font-weight: 700; }
.phone-status-bar .icons { display: flex; gap: 6px; align-items: center; }
.nav-bar {
height: 44px;
display: flex;
align-items: center;
padding: 0 16px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
position: relative;
}
.nav-back {
font-size: 18px;
color: var(--text-primary);
cursor: pointer;
line-height: 1;
text-decoration: none;
}
.nav-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 17px;
font-weight: 600;
color: var(--text-primary);
}
.nav-action {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--primary-dark);
text-decoration: none;
font-weight: 600;
}
.scroll-content {
flex: 1;
overflow-y: auto;
background: linear-gradient(180deg, #ecfbfb 0%, #f7fbfb 160px, #f7fbfb 100%);
}
.scroll-content::-webkit-scrollbar { display: none; }
.page-body { padding: 14px; }
.report-paper {
background: #fff;
border-radius: 20px;
box-shadow: var(--paper-shadow);
border: 1px solid rgba(32, 199, 199, 0.08);
overflow: hidden;
}
.report-header {
padding: 18px 16px 14px;
background: linear-gradient(180deg, #fbffff 0%, #f4ffff 100%);
border-bottom: 1px solid var(--line);
}
.report-header__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.report-header__title {
font-size: 20px;
font-weight: 700;
letter-spacing: 1px;
color: #0f7d7d;
margin-bottom: 6px;
}
.report-header__sub {
font-size: 12px;
color: var(--text-muted);
line-height: 1.6;
}
.report-status {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 62px;
height: 28px;
padding: 0 10px;
border-radius: 14px;
background: #f6ffed;
color: var(--success);
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
}
.summary-strip {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.summary-strip__item {
background: var(--primary-light);
border-radius: 14px;
padding: 10px 8px;
text-align: center;
}
.summary-strip__label {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 4px;
}
.summary-strip__value {
font-size: 15px;
font-weight: 700;
color: var(--primary-dark);
}
.basic-info {
padding: 10px 16px 2px;
}
.basic-info__title,
.report-section__title,
.conclusion-card__title {
height: 30px;
display: flex;
align-items: center;
padding: 0 12px;
margin: 0 -16px 12px;
background: linear-gradient(90deg, #78e3e3 0%, #a4eded 100%);
color: #0c7070;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.5px;
}
.basic-info__grid {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 12px;
row-gap: 10px;
padding-bottom: 12px;
}
.basic-info__item {
display: flex;
align-items: flex-start;
gap: 8px;
min-width: 0;
font-size: 12px;
line-height: 1.5;
}
.basic-info__label {
color: var(--text-muted);
white-space: nowrap;
flex-shrink: 0;
}
.basic-info__value {
color: var(--text-primary);
font-weight: 600;
min-width: 0;
word-break: break-all;
}
.report-section {
padding: 12px 16px 0;
}
.insight-cards {
padding: 14px 16px 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.insight-card {
border: 1px solid var(--border);
border-radius: 16px;
background: linear-gradient(180deg, #fdffff 0%, #f8ffff 100%);
overflow: hidden;
box-shadow: 0 4px 16px rgba(17, 155, 155, 0.05);
}
.insight-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 12px 10px;
}
.insight-card__title {
font-size: 14px;
font-weight: 700;
color: #0d7878;
}
.insight-card__actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.insight-card__voice {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid #c7eded;
background: #fff;
color: var(--primary-dark);
cursor: pointer;
padding: 0;
}
.insight-card__voice svg { width: 18px; height: 18px; }
.insight-card__voice.is-playing {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
.insight-card__content {
position: relative;
padding: 0 12px 12px;
}
.insight-card__text {
font-size: 12px;
line-height: 1.8;
color: #4d4d4d;
max-height: 64px;
overflow: hidden;
transition: max-height 0.25s ease;
}
.insight-card.is-expanded .insight-card__text {
max-height: 320px;
}
.insight-card__fade {
position: absolute;
left: 12px;
right: 12px;
bottom: 40px;
height: 28px;
background: linear-gradient(180deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.96) 100%);
pointer-events: none;
}
.insight-card.is-expanded .insight-card__fade {
display: none;
}
.insight-card__toggle {
margin-top: 10px;
display: inline-flex;
align-items: center;
gap: 4px;
border: none;
background: transparent;
color: var(--primary-dark);
font-size: 12px;
font-weight: 600;
cursor: pointer;
padding: 0;
}
.report-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
margin-bottom: 12px;
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.report-table th,
.report-table td {
border: 1px solid var(--border);
padding: 7px 6px;
font-size: 11px;
line-height: 1.45;
text-align: center;
vertical-align: middle;
word-break: break-word;
}
.report-table th {
background: #f2ffff;
color: #0e7f7f;
font-weight: 700;
}
.report-table td:first-child,
.report-table th:first-child {
text-align: left;
padding-left: 8px;
}
.report-table__hint {
color: var(--text-muted);
}
.is-high,
.is-low,
.is-warn {
color: var(--danger);
font-weight: 700;
}
.flag {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 18px;
padding: 0 6px;
border-radius: 9px;
font-size: 10px;
font-weight: 700;
}
.flag--normal {
background: #f6ffed;
color: var(--success);
}
.flag--warn {
background: var(--danger-light);
color: var(--danger);
}
.conclusion-wrap {
padding: 12px 16px 18px;
}
.conclusion-card {
margin-bottom: 14px;
}
.conclusion-card__box {
border: 1px solid var(--border);
border-radius: 14px;
background: #fbffff;
padding: 12px;
font-size: 12px;
line-height: 1.8;
color: #444;
}
.back-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
height: 46px;
border: none;
border-radius: 23px;
background: linear-gradient(135deg, #13c2c2, #36cfc9);
color: #fff;
font-size: 15px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 8px 20px rgba(19, 194, 194, 0.22);
}
.bottom-spacer { height: 20px; }
</style>
</head>
<body>
<div class="phone-shell">
<div class="phone-status-bar">
<span class="time">12:00</span>
<div class="icons">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<div class="nav-bar">
<!-- [交互] 返回按钮:返回体检报告列表 -->
<span class="nav-back" onclick="location.href='reports.html'"></span>
<span class="nav-title">报告查询</span>
<a class="nav-action" href="javascript:void(0)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
下载
</a>
</div>
<div class="scroll-content">
<div class="page-body">
<div class="report-paper">
<div class="report-header">
<div class="report-header__top">
<div>
<div class="report-header__title">2025年体检报告</div>
<div class="report-header__sub">姓名:张旭
<br>性别:男
<br>年龄:32
<br>身份证号:430626198503250024
<br>体检日期:2025-03-21
<br>体检医院:宁夏宝石花医院健康管理中心
</div>
</div>
<div class="report-status">已出报告</div>
</div>
<!-- <div class="summary-strip">-->
<!-- <div class="summary-strip__item">-->
<!-- <div class="summary-strip__label">总体状态</div>-->
<!-- <div class="summary-strip__value">已完成</div>-->
<!-- </div>-->
<!-- <div class="summary-strip__item">-->
<!-- <div class="summary-strip__label">异常项</div>-->
<!-- <div class="summary-strip__value">6 项</div>-->
<!-- </div>-->
<!-- <div class="summary-strip__item">-->
<!-- <div class="summary-strip__label">体检日期</div>-->
<!-- <div class="summary-strip__value">03-15</div>-->
<!-- </div>-->
<!-- </div>-->
</div>
<div class="insight-cards">
<div class="insight-card" id="reportInterpretationCard">
<div class="insight-card__header">
<div class="insight-card__title">体检报告解读</div>
</div>
<div class="insight-card__content">
<div class="insight-card__text">
本次体检整体完成度较高,绝大多数基础指标处于可接受范围。报告中主要需要关注的是体重管理、血脂水平、尿酸结果以及影像检查中提示的轻度脂肪肝和颈椎生理曲度变化。当前结果更偏向生活方式相关问题,暂未见需要立即进一步处置的急性风险信号。建议重点围绕饮食结构优化、规律运动、减少久坐、保证睡眠进行持续干预,并结合 3 个月后复查结果判断改善效果。
</div>
<div class="insight-card__fade"></div>
<!-- [交互] 点击展开/收起体检报告解读 -->
<button class="insight-card__toggle" type="button" onclick="toggleInsight('reportInterpretationCard', this)">展开</button>
</div>
</div>
<div class="insight-card" id="reportConclusionCard">
<div class="insight-card__header">
<div class="insight-card__title">体检结论</div>
<div class="insight-card__actions">
<!-- [交互] 点击切换语音播报按钮状态 -->
<button class="insight-card__voice" type="button" onclick="toggleVoice(this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>
</button>
</div>
</div>
<div class="insight-card__content">
<div class="insight-card__text">
体检结论显示:BMI 偏高,提示目前处于超重状态;尿酸偏高,需警惕高嘌呤饮食带来的持续影响;血脂中总胆固醇、甘油三酯、低密度脂蛋白存在升高情况,属于代谢类指标异常;彩超提示轻度脂肪肝;颈椎影像提示生理曲度变直。其余血常规、尿常规、血糖、肝功能、甲状腺功能、肿瘤标志物及心电图结果总体平稳。
</div>
<div class="insight-card__fade"></div>
<!-- [交互] 点击展开/收起体检结论 -->
<button class="insight-card__toggle" type="button" onclick="toggleInsight('reportConclusionCard', this)">展开</button>
</div>
</div>
<div class="insight-card" id="reportAdviceCard">
<div class="insight-card__header">
<div class="insight-card__title">体检建议</div>
<div class="insight-card__actions">
<!-- [交互] 点击切换语音播报按钮状态 -->
<button class="insight-card__voice" type="button" onclick="toggleVoice(this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>
</button>
</div>
</div>
<div class="insight-card__content">
<div class="insight-card__text">
建议近期以减重和代谢指标管理为重点:控制油炸食品、动物内脏、甜食和夜宵摄入,增加蔬菜、粗粮和优质蛋白比例;每周保持不少于 150 分钟中等强度有氧运动,并增加颈肩拉伸训练;每日足量饮水,减少含糖饮料和酒精;保持规律作息,避免长期伏案和久坐;建议 3 个月后复查血脂、尿酸、肝脏彩超等相关项目,动态观察改善情况。
</div>
<div class="insight-card__fade"></div>
<!-- [交互] 点击展开/收起体检建议 -->
<button class="insight-card__toggle" type="button" onclick="toggleInsight('reportAdviceCard', this)">展开</button>
</div>
</div>
</div>
<div class="report-section">
<div class="report-section__title">一般检查</div>
<table class="report-table">
<thead>
<tr><th style="width:30%">项目</th><th style="width:24%">结果</th><th style="width:18%">单位</th><th style="width:28%">参考值</th></tr>
</thead>
<tbody>
<tr><td>身高</td><td>163</td><td>cm</td><td>--</td></tr>
<tr><td>体重</td><td>68.5</td><td>kg</td><td>--</td></tr>
<tr><td>BMI</td><td class="is-high">25.8 ↑</td><td>kg/m²</td><td>18.5-23.9</td></tr>
<tr><td>收缩压</td><td>120</td><td>mmHg</td><td>90-139</td></tr>
<tr><td>舒张压</td><td>78</td><td>mmHg</td><td>60-89</td></tr>
<tr><td>脉搏</td><td>76</td><td>次/分</td><td>60-100</td></tr>
</tbody>
</table>
</div>
<div class="report-section">
<div class="report-section__title">内科</div>
<table class="report-table">
<thead>
<tr><th>项目</th><th>检查结果</th><th>单位</th><th>参考值</th></tr>
</thead>
<tbody>
<tr><td>心率</td><td>76</td><td>次/分</td><td>60-100</td></tr>
<tr><td>心律</td><td></td><td>--</td><td>--</td></tr>
<tr><td>心音</td><td>未见异常</td><td>--</td><td>--</td></tr>
<tr><td>呼吸系统</td><td>双肺呼吸音清</td><td>--</td><td>--</td></tr>
<tr><td>腹部触诊</td><td>未见异常</td><td>--</td><td>--</td></tr>
</tbody>
</table>
</div>
<div class="report-section">
<div class="report-section__title">外科</div>
<table class="report-table">
<thead>
<tr><th>项目</th><th>检查结果</th><th>单位</th><th>参考值</th></tr>
</thead>
<tbody>
<tr><td>甲状腺</td><td>未触及肿大</td><td>--</td><td>--</td></tr>
<tr><td>浅表淋巴结</td><td>未见异常</td><td>--</td><td>--</td></tr>
<tr><td>脊柱</td><td>未见异常</td><td>--</td><td>--</td></tr>
<tr><td>四肢关节</td><td>活动可</td><td>--</td><td>--</td></tr>
</tbody>
</table>
</div>
<div class="report-section">
<div class="report-section__title">血常规</div>
<table class="report-table">
<thead>
<tr><th style="width:32%">项目</th><th style="width:22%">结果</th><th style="width:18%">单位</th><th style="width:28%">参考范围</th></tr>
</thead>
<tbody>
<tr><td>白细胞计数 WBC</td><td>6.80</td><td>10^9/L</td><td>3.5-9.5</td></tr>
<tr><td>红细胞计数 RBC</td><td>4.52</td><td>10^12/L</td><td>3.8-5.1</td></tr>
<tr><td>血红蛋白 HGB</td><td>136</td><td>g/L</td><td>115-150</td></tr>
<tr><td>红细胞压积 HCT</td><td>40.5</td><td>%</td><td>35-45</td></tr>
<tr><td>血小板计数 PLT</td><td>248</td><td>10^9/L</td><td>125-350</td></tr>
<tr><td>中性粒细胞比率</td><td>58.6</td><td>%</td><td>40-75</td></tr>
<tr><td>淋巴细胞比率</td><td>33.2</td><td>%</td><td>20-50</td></tr>
</tbody>
</table>
</div>
<div class="report-section">
<div class="report-section__title">尿常规</div>
<table class="report-table">
<thead>
<tr><th>项目</th><th>结果</th><th>单位</th><th>参考范围</th></tr>
</thead>
<tbody>
<tr><td>尿蛋白 PRO</td><td>阴性</td><td>--</td><td>阴性</td></tr>
<tr><td>尿糖 GLU</td><td>阴性</td><td>--</td><td>阴性</td></tr>
<tr><td>酮体 KET</td><td>阴性</td><td>--</td><td>阴性</td></tr>
<tr><td>潜血 BLD</td><td>阴性</td><td>--</td><td>阴性</td></tr>
<tr><td>白细胞 LEU</td><td>阴性</td><td>--</td><td>阴性</td></tr>
<tr><td>尿比重 SG</td><td>1.020</td><td>--</td><td>1.005-1.030</td></tr>
<tr><td>尿酸碱度 pH</td><td>6.0</td><td>--</td><td>5.0-8.0</td></tr>
</tbody>
</table>
</div>
<div class="report-section">
<div class="report-section__title">肝功能</div>
<table class="report-table">
<thead>
<tr><th>项目</th><th>结果</th><th>单位</th><th>参考范围</th></tr>
</thead>
<tbody>
<tr><td>丙氨酸氨基转移酶 ALT</td><td>24.6</td><td>U/L</td><td>7-40</td></tr>
<tr><td>天门冬氨酸氨基转移酶 AST</td><td>22.4</td><td>U/L</td><td>13-35</td></tr>
<tr><td>总蛋白 TP</td><td>74.8</td><td>g/L</td><td>65-85</td></tr>
<tr><td>白蛋白 ALB</td><td>44.2</td><td>g/L</td><td>40-55</td></tr>
<tr><td>总胆红素 TBIL</td><td>12.1</td><td>μmol/L</td><td>5-21</td></tr>
</tbody>
</table>
</div>
<div class="report-section">
<div class="report-section__title">肾功能</div>
<table class="report-table">
<thead>
<tr><th>项目</th><th>结果</th><th>单位</th><th>参考范围</th></tr>
</thead>
<tbody>
<tr><td>尿素 UREA</td><td>4.8</td><td>mmol/L</td><td>2.8-7.2</td></tr>
<tr><td>肌酐 CREA</td><td>71</td><td>μmol/L</td><td>41-73</td></tr>
<tr><td>尿酸 UA</td><td class="is-high">389 ↑</td><td>μmol/L</td><td>155-357</td></tr>
</tbody>
</table>
</div>
<div class="report-section">
<div class="report-section__title">血脂</div>
<table class="report-table">
<thead>
<tr><th>项目</th><th>结果</th><th>单位</th><th>参考范围</th></tr>
</thead>
<tbody>
<tr><td>总胆固醇 TC</td><td class="is-high">6.10 ↑</td><td>mmol/L</td><td>3.1-5.7</td></tr>
<tr><td>甘油三酯 TG</td><td class="is-high">2.08 ↑</td><td>mmol/L</td><td>0.4-1.7</td></tr>
<tr><td>高密度脂蛋白 HDL-C</td><td>1.18</td><td>mmol/L</td><td>1.04-1.55</td></tr>
<tr><td>低密度脂蛋白 LDL-C</td><td class="is-high">3.92 ↑</td><td>mmol/L</td><td>0-3.4</td></tr>
</tbody>
</table>
</div>
<div class="report-section">
<div class="report-section__title">血糖</div>
<table class="report-table">
<thead>
<tr><th>项目</th><th>结果</th><th>单位</th><th>参考范围</th></tr>
</thead>
<tbody>
<tr><td>空腹血糖 GLU</td><td>5.20</td><td>mmol/L</td><td>3.9-6.1</td></tr>
<tr><td>糖化血红蛋白 HbA1c</td><td>5.6</td><td>%</td><td>4.0-6.0</td></tr>
</tbody>
</table>
</div>
<div class="report-section">
<div class="report-section__title">甲状腺功能</div>
<table class="report-table">
<thead>
<tr><th>项目</th><th>结果</th><th>单位</th><th>参考范围</th></tr>
</thead>
<tbody>
<tr><td>促甲状腺激素 TSH</td><td>2.26</td><td>mIU/L</td><td>0.27-4.20</td></tr>
<tr><td>游离三碘甲状腺原氨酸 FT3</td><td>4.88</td><td>pmol/L</td><td>3.1-6.8</td></tr>
<tr><td>游离甲状腺素 FT4</td><td>15.4</td><td>pmol/L</td><td>12-22</td></tr>
</tbody>
</table>
</div>
<div class="report-section">
<div class="report-section__title">肿瘤标志物</div>
<table class="report-table">
<thead>
<tr><th>项目</th><th>结果</th><th>单位</th><th>参考范围</th></tr>
</thead>
<tbody>
<tr><td>癌胚抗原 CEA</td><td>1.82</td><td>ng/mL</td><td>0-5.0</td></tr>
<tr><td>甲胎蛋白 AFP</td><td>3.44</td><td>ng/mL</td><td>0-8.1</td></tr>
<tr><td>糖类抗原 CA125</td><td>18.6</td><td>U/mL</td><td>0-35</td></tr>
</tbody>
</table>
</div>
<div class="report-section">
<div class="report-section__title">心电图</div>
<table class="report-table">
<thead>
<tr><th style="width:30%">项目</th><th style="width:36%">结果</th><th style="width:14%">单位</th><th style="width:20%">参考</th></tr>
</thead>
<tbody>
<tr><td>十二导联心电图</td><td>窦性心律</td><td>--</td><td>--</td></tr>
<tr><td>心电图提示</td><td>未见明显异常</td><td>--</td><td>--</td></tr>
</tbody>
</table>
</div>
<div class="report-section">
<div class="report-section__title">彩超检查</div>
<table class="report-table">
<thead>
<tr><th>检查项目</th><th>检查结果</th><th>单位</th><th>参考</th></tr>
</thead>
<tbody>
<tr><td>肝胆脾胰双肾彩超</td><td>轻度脂肪肝</td><td>--</td><td>--</td></tr>
<tr><td>甲状腺彩超</td><td>甲状腺双叶回声均匀</td><td>--</td><td>--</td></tr>
<tr><td>乳腺彩超</td><td>未见明显异常</td><td>--</td><td>--</td></tr>
</tbody>
</table>
</div>
<div class="report-section">
<div class="report-section__title">影像检查</div>
<table class="report-table">
<thead>
<tr><th>检查项目</th><th>检查结果</th><th>单位</th><th>参考</th></tr>
</thead>
<tbody>
<tr><td>胸部 DR</td><td>心肺膈未见明显异常</td><td>--</td><td>--</td></tr>
<tr><td>颈椎 CT</td><td>颈椎生理曲度变直</td><td>--</td><td>--</td></tr>
</tbody>
</table>
</div>
<div class="conclusion-wrap">
<!-- [交互] 返回报告列表按钮 -->
<button class="back-btn" onclick="location.href='reports.html'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
返回报告列表
</button>
</div>
</div>
<div class="bottom-spacer"></div>
</div>
</div>
</div>
<script>
// [交互] 展开/收起洞察卡片
function toggleInsight(cardId, btn) {
var card = document.getElementById(cardId);
var content = card.querySelector('.insight-card__content');
var fade = card.querySelector('.insight-card__fade');
var isExpanded = card.classList.toggle('is-expanded');
btn.textContent = isExpanded ? '收起' : '展开';
if (fade) fade.style.display = isExpanded ? 'none' : '';
}
// [交互] 语音播报按钮切换(原型演示,仅切换状态)
function toggleVoice(btn) {
btn.classList.toggle('is-playing');
}
</script>
</body>
</html>
+157
View File
@@ -0,0 +1,157 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>体检报告</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #f0f2f5;
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
}
.phone-shell {
width: 390px; min-height: 844px; background: #f7f9fb; border-radius: 44px;
box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0;
overflow: hidden; position: relative; display: flex; flex-direction: column;
}
.phone-status-bar {
height: 44px; background: #fff; display: flex; align-items: center;
justify-content: space-between; padding: 0 24px; font-size: 12px; font-weight: 600; flex-shrink: 0;
}
.phone-status-bar .time { font-size: 15px; font-weight: 700; }
.phone-status-bar .icons { display: flex; gap: 6px; align-items: center; }
.nav-bar {
height: 44px; display: flex; align-items: center; padding: 0 16px;
background: #fff; border-bottom: 1px solid #f0f0f0; flex-shrink: 0; position: relative;
}
.nav-back { font-size: 18px; color: #1a1a1a; cursor: pointer; line-height: 1; text-decoration: none; }
.nav-title { position: absolute; left: 50%; transform: translateX(-50%); font-size: 17px; font-weight: 600; color: #1a1a1a; }
.scroll-content { flex: 1; overflow-y: auto; background: linear-gradient(180deg, #f3fbfb 0%, #f7f9fb 160px, #f7f9fb 100%); }
.scroll-content::-webkit-scrollbar { display: none; }
.page-body { padding: 16px; }
/* 顶部 AI 解读大卡片 */
.ai-card {
background: linear-gradient(135deg, #ffffff 0%, #e6fffb 100%);
border: 1px solid #b5f5ec; border-radius: 20px; padding: 20px 18px;
box-shadow: 0 6px 18px rgba(20, 180, 176, 0.10); position: relative; overflow: hidden;
}
.ai-card::after {
content: ''; position: absolute; right: -30px; top: -30px; width: 120px; height: 120px;
border-radius: 50%; background: rgba(19, 194, 194, 0.08);
}
.ai-card__badge {
display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px;
border-radius: 13px; background: rgba(19, 194, 194, 0.12); color: #08979c;
font-size: 12px; font-weight: 600; margin-bottom: 10px;
}
.ai-card__title { font-size: 20px; font-weight: 700; color: #1a1a1a; margin-bottom: 4px; position: relative; z-index: 1; }
.ai-card__sub { font-size: 13px; color: #13c2c2; font-weight: 600; margin-bottom: 8px; position: relative; z-index: 1; }
.ai-card__desc { font-size: 13px; line-height: 1.7; color: #666; position: relative; z-index: 1; }
/* 报告列表 */
.section-title { font-size: 16px; font-weight: 700; color: #1a1a1a; margin: 22px 2px 12px; }
.report-list { display: flex; flex-direction: column; gap: 12px; }
.report-item {
background: #fff; border-radius: 16px; padding: 16px; display: flex; align-items: center; gap: 14px;
box-shadow: 0 4px 14px rgba(0,0,0,0.05); border: 1px solid #f0f0f0; cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.report-item:active { transform: scale(0.985); }
.report-item__icon {
width: 48px; height: 48px; border-radius: 14px; display: flex; align-items: center;
justify-content: center; flex-shrink: 0; background: #fff7e6; color: #fa8c16;
}
.report-item__content { flex: 1; min-width: 0; }
.report-item__title { font-size: 15px; font-weight: 600; color: #1a1a1a; margin-bottom: 4px; }
.report-item__meta { font-size: 12px; color: #8c8c8c; }
.report-item__arrow { font-size: 18px; color: #c6c6c6; flex-shrink: 0; }
.bottom-spacer { height: 28px; }
</style>
</head>
<body>
<div class="phone-shell">
<div class="phone-status-bar">
<span class="time">12:00</span>
<div class="icons">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<div class="nav-bar">
<!-- [交互] 返回按钮:返回健康体检主页 -->
<span class="nav-back" onclick="location.href='index.html'"></span>
<span class="nav-title">体检报告</span>
</div>
<div class="scroll-content">
<div class="page-body">
<!-- 顶部 AI 解读大卡片 -->
<div class="ai-card">
<div class="ai-card__badge">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span>AI解读</span>
</div>
<div class="ai-card__title">体检报告解读</div>
<div class="ai-card__desc">依托 AI 技术,自动解析体检报告数据,提供专业指标解读、风险评估与个性化健康指导。</div>
<br>
<div class="ai-card__sub">查看报告解读</div>
</div>
<!-- 报告列表 -->
<div class="section-title">我的历年报告</div>
<div class="report-list">
<!-- [交互] 点击跳转至: report-query.html -->
<div class="report-item" onclick="location.href='report-query.html'">
<div class="report-item__icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="14" y2="17"/></svg>
</div>
<div class="report-item__content">
<div class="report-item__title">2025年度胃肠镜体检报告</div>
<div class="report-item__meta">长庆星龙园医院</div>
</div>
<span class="report-item__arrow"></span>
</div>
<!-- [交互] 点击跳转至: report-query.html -->
<div class="report-item" onclick="location.href='report-query.html'">
<div class="report-item__icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="14" y2="17"/></svg>
</div>
<div class="report-item__content">
<div class="report-item__title">2025年度常规体检报告</div>
<div class="report-item__meta">长庆星龙园医院</div>
</div>
<span class="report-item__arrow"></span>
</div>
<!-- [交互] 点击跳转至: report-query.html -->
<div class="report-item" onclick="location.href='report-query.html'">
<div class="report-item__icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="14" y2="17"/></svg>
</div>
<div class="report-item__content">
<div class="report-item__title">2024年度常规体检报告</div>
<div class="report-item__meta">长庆星龙园医院</div>
</div>
<span class="report-item__arrow"></span>
</div>
<!-- [交互] 点击跳转至: report-query.html -->
<div class="report-item" onclick="location.href='report-query.html'">
<div class="report-item__icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="14" y2="17"/></svg>
</div>
<div class="report-item__content">
<div class="report-item__title">2023年度常规体检报告</div>
<div class="report-item__meta">长庆星龙园医院</div>
</div>
<span class="report-item__arrow"></span>
</div>
</div>
<div class="bottom-spacer"></div>
</div>
</div>
</div>
</body>
</html>
+1 -16
View File
@@ -154,7 +154,7 @@
<!-- [交互] 导航栏:返回按钮 + 标题 + 右侧消息图标 -->
<div class="nav-bar">
<span class="nav-back" onclick="goBackFromSource()">← 返回</span>
<span class="nav-back" onclick="location.href='../home/index.html'">← 返回</span>
<span class="nav-title">健康咨询</span>
</div>
@@ -261,20 +261,5 @@
<div class="bottom-spacer"></div>
</div><!-- /.phone-shell -->
<script>
function goBackFromSource() {
var params = new URLSearchParams(window.location.search);
var from = params.get('from');
if (from === 'index') {
window.location.href = '../home/index.html';
return;
}
if (document.referrer) {
history.back();
return;
}
window.location.href = '../home/index.html';
}
</script>
</body>
</html>
@@ -157,7 +157,7 @@
<!-- 温馨提示 -->
<div class="tip-box">📌 温馨提示:请在预约时间前5分钟准备好,保持网络畅通,等待医生拨打视频通话</div>
<div class="tip-box">📌 温馨提示:请在预约时间保持网络畅通,等待医生拨打视频通话</div>
</div>
<!-- [交互] 底部操作栏 -->
@@ -0,0 +1,329 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>血氧预警历史 - 健康监测 | 健康长庆原型</title>
<style>
/* 全局重置 */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #f0f2f5;
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
}
/* 手机外壳 */
.phone-shell {
width: 390px;
height: 844px;
background: #f5f7fa;
border-radius: 44px;
box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
}
/* 状态栏 */
.status-bar {
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
flex-shrink: 0;
background: #fff;
}
.status-bar .time { font-size: 15px; font-weight: 700; color: #333; }
.status-bar .icons { display: flex; gap: 6px; align-items: center; }
/* 导航栏 */
.nav-bar {
height: 44px;
background: #fff;
display: flex;
align-items: center;
padding: 0 16px;
flex-shrink: 0;
border-bottom: 1px solid #f0f0f0;
}
.nav-back {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.nav-title {
flex: 1;
text-align: center;
font-size: 17px;
font-weight: 600;
color: #1a1a1a;
margin-right: 28px;
}
/* 滚动内容区 */
.scroll-content {
flex: 1;
overflow-y: auto;
background: #f5f7fa;
}
.scroll-content::-webkit-scrollbar { display: none; }
/* 预警统计卡片 */
.stats-card {
margin: 12px 12px 0;
background: #fff;
border-radius: 14px;
padding: 20px 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
.stats-card__left { display: flex; flex-direction: column; align-items: flex-start; }
.stats-card__label { font-size: 13px; color: #999; margin-bottom: 6px; }
.stats-card__count-row { display: flex; align-items: baseline; gap: 4px; }
/* 本月无预警,数字显示绿色 */
.stats-card__count { font-size: 40px; font-weight: 800; color: #52c41a; line-height: 1; }
.stats-card__unit { font-size: 14px; color: #52c41a; font-weight: 600; }
.stats-card__right { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
.stats-card__recent-label { font-size: 12px; color: #999; }
.stats-card__recent-time { font-size: 13px; font-weight: 600; color: #333; }
/* 预警图标装饰 */
.stats-card__icon {
width: 48px;
height: 48px;
background: #f6ffed;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
}
/* 列表区块标题 */
.section-title {
font-size: 13px;
color: #999;
padding: 14px 16px 8px;
font-weight: 500;
}
/* 预警记录卡片 */
.alert-card {
margin: 0 12px 10px;
background: #fff;
border-radius: 14px;
padding: 14px 14px 14px 12px;
display: flex;
gap: 12px;
align-items: flex-start;
}
/* 左侧等级圆点 */
.alert-card__dot-col {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 3px;
gap: 4px;
}
.alert-card__dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
/* 严重:红色 */
.alert-card__dot--critical { background: #ff4d4f; box-shadow: 0 0 0 3px rgba(255,77,79,0.18); }
/* 卡片主体内容 */
.alert-card__body { flex: 1; min-width: 0; }
.alert-card__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.alert-card__time { font-size: 13px; color: #666; }
.alert-card__badge {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
flex-shrink: 0;
}
/* 严重标签:红底红字 */
.alert-card__badge--critical { background: #fff1f0; color: #ff4d4f; }
/* 监测值 */
.alert-card__value-row {
display: flex;
align-items: baseline;
gap: 4px;
margin-bottom: 6px;
}
.alert-card__value { font-size: 24px; font-weight: 800; color: #1a1a1a; line-height: 1; }
.alert-card__value-unit { font-size: 13px; color: #999; }
/* 详情行 */
.alert-card__detail-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.alert-card__detail-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #999;
}
.alert-card__detail-item svg { flex-shrink: 0; }
/* 触发原因 */
.alert-card__reason {
margin-top: 8px;
font-size: 12px;
color: #bbb;
background: #fafafa;
border-radius: 6px;
padding: 6px 8px;
line-height: 1.5;
}
/* 底部安全区 */
.bottom-safe { height: 20px; flex-shrink: 0; }
</style>
</head>
<body>
<div class="phone-shell">
<!-- 状态栏 -->
<div class="status-bar">
<span class="time">9:41</span>
<div class="icons">
<!-- WiFi 图标 -->
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2">
<path d="M5 12.55a11 11 0 0 1 14.08 0"/>
<path d="M1.42 9a16 16 0 0 1 21.16 0"/>
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"/>
<circle cx="12" cy="20" r="1" fill="#333" stroke="none"/>
</svg>
<!-- 电池图标 -->
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2">
<rect x="1" y="6" width="18" height="12" rx="2"/>
<path d="M21 10v4"/>
<rect x="3" y="8" width="12" height="8" rx="1" fill="#333" stroke="none"/>
</svg>
</div>
</div>
<!-- 导航栏:返回血氧详情页 -->
<div class="nav-bar">
<div class="nav-back" onclick="location.href='blood-oxygen.html'">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2" stroke-linecap="round">
<path d="M15 19l-7-7 7-7"/>
</svg>
</div>
<div class="nav-title">血氧预警历史</div>
</div>
<!-- 滚动内容区 -->
<div class="scroll-content">
<!-- 预警统计卡片 -->
<div class="stats-card">
<div class="stats-card__left">
<div class="stats-card__label">本月预警</div>
<div class="stats-card__count-row">
<span class="stats-card__count">0</span>
<span class="stats-card__unit"></span>
</div>
</div>
<div class="stats-card__right">
<!-- 血氧水滴图标,绿色背景表示正常 -->
<div class="stats-card__icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M12 2C12 2 5 9.5 5 14a7 7 0 0 0 14 0C19 9.5 12 2 12 2z" fill="#52c41a" opacity="0.85"/>
<path d="M9 13.5a3 3 0 0 0 3 3" stroke="#fff" stroke-width="1.5" stroke-linecap="round" opacity="0.7"/>
</svg>
</div>
<div class="stats-card__recent-label">最近预警时间</div>
<div class="stats-card__recent-time">暂无</div>
</div>
</div>
<!-- 空状态:本月无预警记录 -->
<!-- <div class="empty-state">-->
<!-- &lt;!&ndash; 大图标:绿色盾牌表示健康安全 &ndash;&gt;-->
<!-- <div class="empty-state__icon">-->
<!-- <svg width="40" height="40" viewBox="0 0 24 24" fill="none">-->
<!-- <path d="M12 2L3 7v5c0 5.25 3.75 10.15 9 11.35C17.25 22.15 21 17.25 21 12V7L12 2z" fill="#52c41a" opacity="0.15" stroke="#52c41a" stroke-width="1.5" stroke-linejoin="round"/>-->
<!-- &lt;!&ndash; 对勾 &ndash;&gt;-->
<!-- <polyline points="9 12 11 14 15 10" stroke="#52c41a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>-->
<!-- </svg>-->
<!-- </div>-->
<!-- <div class="empty-state__text">暂无预警记录,请继续保持</div>-->
<!-- </div>-->
<!-- 预警记录列表标题 -->
<div class="section-title">预警记录(共 1 条)</div>
<!-- 历史预警记录:2026-03-08 严重 -->
<div class="alert-card">
<!-- 左侧严重等级红点 -->
<div class="alert-card__dot-col">
<div class="alert-card__dot alert-card__dot--critical"></div>
</div>
<div class="alert-card__body">
<!-- 时间 + 严重等级标签 -->
<div class="alert-card__header">
<span class="alert-card__time">2026-03-08 03:22</span>
<span class="alert-card__badge alert-card__badge--critical">严重</span>
</div>
<!-- 血氧监测值 -->
<div class="alert-card__value-row">
<span class="alert-card__value">93</span>
<span class="alert-card__value-unit">%</span>
</div>
<!-- 持续时长 + 血氧标签 -->
<div class="alert-card__detail-row">
<!-- 时钟图标 + 持续时长 -->
<div class="alert-card__detail-item">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#bbb" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
<span>持续 8 分钟</span>
</div>
<!-- 血氧水滴图标 + 标签 -->
<div class="alert-card__detail-item">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#bbb" stroke-width="2">
<path d="M12 2C12 2 6 9 6 14a6 6 0 0 0 12 0C18 9 12 2 12 2z"/>
</svg>
<span>血氧</span>
</div>
</div>
<!-- 触发原因 -->
<div class="alert-card__reason">触发原因:血氧低于 95% 阈值</div>
</div>
</div>
<!-- 底部安全区 -->
<div class="bottom-safe"></div>
</div><!-- /scroll-content -->
</div><!-- /phone-shell -->
</body>
</html>
@@ -0,0 +1,312 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>血氧详情 - 健康监测 | 健康长庆原型</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #f0f2f5; display: flex; justify-content: center; align-items: flex-start; min-height: 100vh; padding: 20px; font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif; }
.phone-shell { width: 390px; height: 844px; background: #f5f7fa; border-radius: 44px; box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0; overflow: hidden; position: relative; display: flex; flex-direction: column; }
.status-bar { height: 44px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; flex-shrink: 0; background: #fff; }
.status-bar .time { font-size: 15px; font-weight: 700; color: #333; }
.status-bar .icons { display: flex; gap: 6px; align-items: center; }
.nav-bar { height: 44px; background: #fff; display: flex; align-items: center; padding: 0 16px; flex-shrink: 0; border-bottom: 1px solid #f0f0f0; }
.nav-back { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.nav-title { flex: 1; text-align: center; font-size: 17px; font-weight: 600; color: #1a1a1a; margin-right: 28px; }
.scroll-content { flex: 1; overflow-y: auto; background: #f5f7fa; }
.scroll-content::-webkit-scrollbar { display: none; }
/* 主题色(血氧绿) */
:root { --theme: #52c41a; --theme2: #95de64; }
.top-panel { background: #fff; padding-top: 2px; }
.time-tabs { display: flex; justify-content: center; gap: 42px; padding: 2px 0 8px; }
.time-tab { position: relative; font-size: 18px; color: #444; cursor: pointer; padding: 0 2px 6px; user-select: none; }
.time-tab.active { color: var(--theme); }
.time-tab.active::after { content: ''; position: absolute; left: 0; right: 0; bottom: 0; margin: auto; width: 22px; height: 3px; border-radius: 2px; background: var(--theme); }
.summary-area { text-align: center; padding: 12px 24px 6px; }
.date-row { display: inline-flex; align-items: center; gap: 6px; font-size: 16px; color: #666; font-weight: 500; }
.range-text { margin-top: 4px; font-size: 16px; color: #666; }
.main-row { margin-top: 2px; display: inline-flex; align-items: baseline; gap: 4px; }
.main-row__value { font-size: 48px; line-height: 1; font-weight: 500; color: #1f1f1f; letter-spacing: -1px; }
.main-row__unit { font-size: 18px; color: #333; }
.main-badge { display: inline-flex; align-items: center; justify-content: center; margin-top: 6px; padding: 3px 12px; border-radius: 18px; font-size: 12px; font-weight: 600; background: #f6ffed; color: #52c41a; }
.chart-wrap { position: relative; background: #fff; padding: 6px 16px 0; }
.chart-box { position: relative; height: 160px; }
.chart-svg { width: 100%; height: 100%; display: block; }
.chart-grid line { stroke: #ececec; stroke-width: 1; }
.chart-path { fill: none; stroke: var(--theme); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.chart-dot { fill: var(--theme); }
.y-axis-labels { position: absolute; top: 6px; right: 0; width: 56px; height: 208px; pointer-events: none; }
.y-axis-label { position: absolute; right: 0; transform: translateY(50%); font-size: 12px; color: #666; }
.x-axis { display: grid; gap: 0; padding: 8px 14px 0 0; font-size: 12px; color: #666; }
.x-axis span { text-align: center; white-space: nowrap; }
.x-axis--dense { font-size: 10px; }
.timeline-knob-row { position: relative; margin: 0; height: 24px; }
.timeline-track { position: absolute; left: 0; right: 0; top: 10px; height: 1px; background: #e8e8e8; }
.timeline-knob { position: absolute; right: 16px; top: -4px; width: 24px; height: 24px; border-radius: 50%; background: #fff; box-shadow: 0 2px 10px rgba(0,0,0,0.12); }
.card { margin: 8px 12px 0; background: #fff; border-radius: 22px; padding: 14px 12px; display: flex; gap: 0; }
.metric-row { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 6px 6px; }
.metric-left__label { font-size: 12px; color: #777; margin-bottom: 4px; }
.metric-left__value { display: flex; align-items: baseline; gap: 2px; }
.metric-left__num { font-size: 16px; font-weight: 600; color: #222; }
.metric-left__unit { font-size: 12px; color: #777; }
.metric-right { font-size: 12px; color: #777; text-align: center; }
.metric-split { width: 1px; background: #ececec; margin: 0; }
.alert-btn { margin: 8px 12px 0; border: 1.5px solid var(--theme); border-radius: 12px; padding: 11px; text-align: center; font-size: 13px; color: var(--theme); font-weight: 600; cursor: pointer; background: #fff; display: flex; align-items: center; justify-content: center; gap: 6px; }
.foot-note { padding: 10px 0 12px; text-align: center; font-size: 11px; color: #b3b3b3; }
.bottom-safe { height: 12px; flex-shrink: 0; }
</style>
</head>
<body>
<div class="phone-shell">
<div class="status-bar">
<span class="time">9:41</span>
<div class="icons">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<div class="nav-bar">
<div class="nav-back" onclick="location.href='index.html'">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2" stroke-linecap="round"><path d="M15 19l-7-7 7-7"/></svg>
</div>
<div class="nav-title">血氧</div>
</div>
<div class="scroll-content">
<div class="top-panel">
<div class="time-tabs">
<div class="time-tab active" data-mode="day"></div>
<div class="time-tab" data-mode="week"></div>
<div class="time-tab" data-mode="month"></div>
<div class="time-tab" data-mode="year"></div>
</div>
<div class="summary-area">
<div class="date-row">
<span id="dateText">4月30日 周四</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="#666"><path d="M7 10l5 5 5-5z"/></svg>
</div>
<div class="range-text" id="rangeText">00:00-23:59</div>
<div class="main-row">
<span class="main-row__value" id="mainValue">98</span>
<span class="main-row__unit" id="mainUnit">%</span>
</div>
<div class="main-badge" id="statusBadge">正常</div>
</div>
<div class="chart-wrap">
<div class="chart-box">
<svg class="chart-svg" id="chart" viewBox="0 0 358 258" preserveAspectRatio="none">
<g class="chart-grid" id="chartGrid"></g>
<path id="line" class="chart-path"></path>
<circle id="dot" class="chart-dot" r="3.2"></circle>
</svg>
<div class="y-axis-labels" id="yAxisLabels"></div>
</div>
<div class="x-axis" id="xAxis"></div>
<div class="timeline-knob-row">
<div class="timeline-track"></div>
<div class="timeline-knob"></div>
</div>
</div>
</div>
<div class="card">
<div class="metric-row">
<div class="metric-left">
<div class="metric-left__label">最高</div>
<div class="metric-left__value"><span class="metric-left__num" id="maxValue">99</span><span class="metric-left__unit">%</span></div>
</div>
<div class="metric-right" id="maxHint">10:10</div>
</div>
<div class="metric-split"></div>
<div class="metric-row">
<div class="metric-left">
<div class="metric-left__label">最低</div>
<div class="metric-left__value"><span class="metric-left__num" id="minValue">95</span><span class="metric-left__unit">%</span></div>
</div>
<div class="metric-right" id="minHint">02:50</div>
</div>
<div class="metric-split"></div>
<div class="metric-row">
<div class="metric-left">
<div class="metric-left__label">平均</div>
<div class="metric-left__value"><span class="metric-left__num" id="avgValue">97</span><span class="metric-left__unit">%</span></div>
</div>
<div class="metric-right" id="avgHint">全天</div>
</div>
</div>
<div class="alert-btn" onclick="location.href='blood-oxygen-alerts.html'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--theme)" stroke-width="2"><path d="M12 9v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
查看预警历史
</div>
<div class="foot-note">以上数据为原型示例,实际以设备同步为准</div>
<div class="bottom-safe"></div>
</div>
</div>
<script>
var dataMap = {
day: {
dateText: '4月30日 周四',
rangeText: '00:00-23:59',
unit: '%',
decimals: 0,
points: [97,97,96,96,95,96,96,97,98,98,97,97,98,99,98,97,97,96,97,98,98,97,97,97],
totalMode: 'last',
yTicks: [100, 98, 96, 94],
xLabels: ['0:00','6:00','12:00','18:00','24:00'],
status: '正常',
max: { v:99, hint:'10:10' },
min: { v:95, hint:'02:50' },
avg: { v:97, hint:'全天' }
},
week: {
dateText: '4月27日-5月3日',
rangeText: '周一-周日',
unit: '%',
decimals: 0,
points: [97,96,98,97,97,98,98],
totalMode: 'avg',
yTicks: [100, 98, 96, 94],
xLabels: ['周一','周二','周三','周四','周五','周六','周日'],
status: '正常',
max: { v:98, hint:'周三' },
min: { v:96, hint:'周二' },
avg: { v:97, hint:'本周' }
},
month: {
dateText: '2026年4月',
rangeText: '4月1日-4月30日',
unit: '%',
decimals: 0,
points: [97,97,96,96,95,96,96,97,98,98,97,97,98,99,98,97,97,96,97,98,98,97,97,97,97,97,96,96,95,96],
totalMode: 'avg',
yTicks: [100, 98, 96, 94],
xLabels: ['1日','7日','14日','21日','30日'],
status: '正常',
max: { v:99, hint:'04-14' },
min: { v:95, hint:'04-05' },
avg: { v:97, hint:'本月' }
},
year: {
dateText: '2026年',
rangeText: '1月-12月',
unit: '%',
decimals: 0,
points: [97,97,96,96,97,96,96,97,98,97,96,97],
totalMode: 'avg',
yTicks: [100, 98, 96, 94],
xLabels: ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'],
status: '正常',
max: { v:98, hint:'01月' },
min: { v:96, hint:'03月' },
avg: { v:97, hint:'全年' }
}
};
function avg(arr) {
if (!arr.length) return 0;
var sum = arr.reduce(function(s, v){ return s + v; }, 0);
return sum / arr.length;
}
function getValue(cfg) {
if (cfg.totalMode === 'last') return cfg.points[cfg.points.length - 1];
if (cfg.totalMode === 'avg') return avg(cfg.points);
if (cfg.totalMode === 'max') return Math.max.apply(null, cfg.points);
if (cfg.totalMode === 'min') return Math.min.apply(null, cfg.points);
return cfg.points[cfg.points.length - 1];
}
function formatValue(val, decimals) {
if (typeof val !== 'number') return String(val);
if (decimals != null) return val.toFixed(decimals);
return String(val);
}
function renderChart(cfg) {
var chartWidth = 330;
var chartHeight = 208;
var startY = 22;
var stepX = chartWidth / (cfg.points.length - 1 || 1);
var maxY = cfg.yTicks[0];
var grid = document.getElementById('chartGrid');
var yAxis = document.getElementById('yAxisLabels');
var xAxis = document.getElementById('xAxis');
var lineEl = document.getElementById('line');
var dotEl = document.getElementById('dot');
grid.innerHTML = '';
cfg.yTicks.forEach(function(_, index) {
var y = startY + (chartHeight / (cfg.yTicks.length - 1)) * index;
grid.innerHTML += '<line x1="0" y1="' + y.toFixed(1) + '" x2="330" y2="' + y.toFixed(1) + '"></line>';
});
yAxis.innerHTML = cfg.yTicks.map(function(tick, index) {
return '<span class="y-axis-label" style="top:' + (index * 52) + 'px">' + formatValue(tick, 0) + '</span>';
}).join('');
var pts = cfg.points.map(function(v, i) {
var x = i * stepX;
var y = startY + chartHeight - (v / maxY) * chartHeight;
return { x: x, y: y };
});
var d = pts.map(function(p, index) {
return (index === 0 ? 'M' : 'L') + p.x.toFixed(1) + ',' + p.y.toFixed(1);
}).join(' ');
lineEl.setAttribute('d', d);
var last = pts[pts.length - 1];
dotEl.setAttribute('cx', last.x.toFixed(1));
dotEl.setAttribute('cy', last.y.toFixed(1));
xAxis.className = 'x-axis' + (cfg.xLabels.length > 7 ? ' x-axis--dense' : '');
xAxis.style.gridTemplateColumns = 'repeat(' + cfg.xLabels.length + ', 1fr)';
xAxis.innerHTML = cfg.xLabels.map(function(label) {
return '<span>' + label + '</span>';
}).join('');
}
function renderMode(mode) {
var cfg = dataMap[mode];
var value = getValue(cfg);
document.getElementById('dateText').textContent = cfg.dateText;
document.getElementById('rangeText').textContent = cfg.rangeText;
document.getElementById('mainValue').textContent = formatValue(value, cfg.decimals);
document.getElementById('mainUnit').textContent = cfg.unit;
document.getElementById('statusBadge').textContent = cfg.status;
document.getElementById('maxValue').textContent = formatValue(cfg.max.v, cfg.decimals);
document.getElementById('maxHint').textContent = cfg.max.hint;
document.getElementById('minValue').textContent = formatValue(cfg.min.v, cfg.decimals);
document.getElementById('minHint').textContent = cfg.min.hint;
document.getElementById('avgValue').textContent = formatValue(cfg.avg.v, cfg.decimals);
document.getElementById('avgHint').textContent = cfg.avg.hint;
renderChart(cfg);
}
document.querySelectorAll('.time-tab').forEach(function(tab) {
tab.onclick = function() {
document.querySelectorAll('.time-tab').forEach(function(item) { item.classList.remove('active'); });
tab.classList.add('active');
renderMode(tab.getAttribute('data-mode'));
};
});
renderMode('day');
</script>
</body>
</html>
@@ -0,0 +1,87 @@
# 健康监测 - 变更记录
> 本文档记录「健康监测」模块原型的所有变更,按时间倒序排列。
---
## [2026-05-07] refactor: 统一四个预警历史页面布局结构
**涉及文件(4个修改):**
- `blood-oxygen-alerts.html` — 更新 stats-card 结构,添加 stats-card__recent-label 和 stats-card__recent-time,删除 stats-card__sub-label;更新 section-title 文本为"预警记录(共 1 条)"
- `temperature-alerts.html` — 完全重构为标准格式,采用与 heart-rate-alerts.html 相同的 stats-card 和 alert-card 结构,颜色主题使用黄色(#faad14
- `stress-alerts.html` — 已符合标准格式(无需修改)
- `heart-rate-alerts.html` — 参考标准(无需修改)
**变更说明:**
- 四个预警历史页面现采用统一的布局结构:stats-card(左侧统计数据 + 右侧图标和最近预警时间)+ alert-card(左侧等级圆点 + 右侧卡片内容)
- 各页面保持各自的颜色主题:心率红色(#ff7a8a)、血氧绿色(#52c41a)、体温黄色(#faad14)、压力橙色(#fa8c16
- 统一的 CSS 类名和 DOM 结构便于后续维护和扩展
---
## [2026-04-30] refactor: 压缩四个详情页面整体布局高度
**涉及文件(4个修改):**
- `heart-rate.html` — 减少主值字体大小(58px → 48px)、图表高度(258px → 160px)、卡片内边距(18px 12px → 14px 12px
- `temperature.html` — 同步应用布局压缩
- `blood-oxygen.html` — 同步应用布局压缩
- `stress.html` — 同步应用布局压缩
**变更说明:**
- 所有详情页面现可在 844px 手机屏幕内完整显示,无需垂直滚动
- 减少了 150+ 像素的垂直空间占用
---
## [2026-04-30] refactor: 详情页面最高/最低/平均改为一行三列显示
**涉及文件(4个修改):**
- `heart-rate.html` — 将三个指标从垂直堆叠改为水平三列布局,使用 flex: 1 实现等宽分布
- `temperature.html` — 同步应用三列布局
- `blood-oxygen.html` — 同步应用三列布局
- `stress.html` — 同步应用三列布局
**变更说明:**
- 指标卡片从竖向排列改为横向三列,每列包含标签、数值、单位和时间提示
- 使用垂直分隔线(1px 灰色)分隔三列
- 提高了空间利用效率
---
## [2026-04-27] refactor: 血氧和压力详情页现代化设计
**涉及文件(2个修改):**
- `blood-oxygen.html` — 从旧的 hero-card 布局改为现代 top-panel 结构,新增 CSS 变量(--theme: #52c41a)、Tab 导航、图表、时间模式切换
- `stress.html` — 从旧的 hero-card 布局改为现代 top-panel 结构,新增 CSS 变量(--theme: #faad14)、Tab 导航、图表、时间模式切换
**变更说明:**
- 采用与 temperature.html 相同的现代设计风格
- 新增日/周/月/年四种时间维度的数据展示
- 图表使用 SVG 绘制,支持动态数据切换
- 保留各指标的特定说明文案(血氧正常范围、压力等级说明)
---
## [2026-04-21] init: 健康监测模块初始化
**新增文件(10个):**
| 文件 | 说明 |
|------|------|
| `index.html` | 模块首页:四个监测指标入口 |
| `heart-rate.html` | 心率详情:日/周/月/年数据展示 + 预警历史入口 |
| `heart-rate-alerts.html` | 心率预警历史:预警统计 + 预警记录列表 |
| `blood-oxygen.html` | 血氧详情:日/周/月/年数据展示 + 预警历史入口 |
| `blood-oxygen-alerts.html` | 血氧预警历史:预警统计 + 预警记录列表 |
| `temperature.html` | 体温详情:日/周/月/年数据展示 + 预警历史入口 |
| `temperature-alerts.html` | 体温预警历史:预警统计 + 预警记录列表 |
| `stress.html` | 压力详情:日/周/月/年数据展示 + 预警历史入口 |
| `stress-alerts.html` | 压力预警历史:预警统计 + 预警记录列表 |
| `exercise.html` | 运动详情:日/周/月/年数据展示 |
| `sleep.html` | 睡眠详情:日/周/月/年数据展示 |
**变更说明:**
- 健康监测模块包含 6 个主要监测指标(心率、血氧、体温、压力、运动、睡眠)
- 每个指标支持多时间维度数据展示(日/周/月/年)
- 前 4 个指标配备预警历史页面,展示预警统计和详细记录
- 所有页面采用统一的设计规范和交互模式
+670
View File
@@ -0,0 +1,670 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>锻炼详情 - 健康监测 | 健康长庆原型</title>
<style>
/* ===== 全局重置与基础样式 ===== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
body {
background: #e5e5e5;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
}
/* ===== 手机外壳 ===== */
.phone-shell {
width: 390px;
height: 844px;
background: #f5f7fa;
border-radius: 44px;
overflow: hidden;
position: relative;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
/* ===== 状态栏 ===== */
.status-bar {
height: 44px;
background: #ffffff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
flex-shrink: 0;
}
.status-bar .time {
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
}
/* 状态栏右侧图标组 */
.status-icons {
display: flex;
align-items: center;
gap: 6px;
}
/* WiFi 图标(内联SVG */
.icon-wifi {
width: 16px;
height: 12px;
}
/* 电池图标(内联SVG */
.icon-battery {
width: 25px;
height: 12px;
}
/* ===== 导航栏 ===== */
.nav-bar {
height: 44px;
background: #ffffff;
display: flex;
align-items: center;
padding: 0 16px;
position: relative;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
}
/* 返回按钮 */
.nav-back {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
color: #fa8c16;
font-size: 16px;
text-decoration: none;
z-index: 1;
}
.nav-back svg {
width: 10px;
height: 17px;
}
/* 导航标题居中 */
.nav-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 17px;
font-weight: 600;
color: #1a1a1a;
}
/* ===== 滚动内容区 ===== */
.scroll-content {
height: calc(844px - 44px - 44px);
overflow-y: auto;
padding: 16px 16px 20px;
/* 隐藏滚动条但保留滚动功能 */
-ms-overflow-style: none;
scrollbar-width: none;
}
.scroll-content::-webkit-scrollbar {
display: none;
}
/* ===== 通用卡片样式 ===== */
.card {
background: #ffffff;
border-radius: 16px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.card-title {
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 14px;
}
/* ===== 本周概览卡片 ===== */
.overview-card {
background: linear-gradient(135deg, #fa8c16 0%, #ff6b00 100%);
border-radius: 16px;
padding: 18px 16px;
margin-bottom: 12px;
box-shadow: 0 4px 16px rgba(250, 140, 22, 0.35);
}
.overview-card .card-title {
color: rgba(255, 255, 255, 0.9);
margin-bottom: 16px;
}
/* 概览统计四格布局 */
.overview-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.stat-item {
background: rgba(255, 255, 255, 0.18);
border-radius: 12px;
padding: 12px;
text-align: center;
}
.stat-value {
font-size: 20px;
font-weight: 700;
color: #ffffff;
line-height: 1.2;
}
.stat-unit {
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
margin-left: 2px;
}
.stat-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.75);
margin-top: 4px;
}
/* ===== 时间维度 Tab ===== */
.tab-bar {
background: #ffffff;
border-radius: 16px;
padding: 6px;
margin-bottom: 12px;
display: flex;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.tab-item {
flex: 1;
text-align: center;
padding: 8px 0;
font-size: 14px;
font-weight: 500;
color: #8c8c8c;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
}
/* 激活状态的 Tab */
.tab-item.active {
background: #fa8c16;
color: #ffffff;
font-weight: 600;
}
/* ===== 锻炼记录列表 ===== */
.section-title {
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 10px;
padding-left: 2px;
}
/* 单条锻炼记录卡片 */
.exercise-card {
background: #ffffff;
border-radius: 16px;
padding: 14px 16px;
margin-bottom: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
/* 记录卡片顶部:图标+名称 + 日期时间 */
.exercise-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.exercise-type {
display: flex;
align-items: center;
gap: 8px;
}
/* 运动类型图标背景圆 */
.exercise-icon {
width: 36px;
height: 36px;
border-radius: 50%;
background: #fff7e6;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.exercise-name {
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
}
.exercise-datetime {
font-size: 12px;
color: #8c8c8c;
}
/* 记录卡片底部:四项数据指标 */
.exercise-metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
padding-top: 12px;
border-top: 1px solid #f5f5f5;
}
.metric-item {
text-align: center;
}
.metric-value {
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
}
.metric-unit {
font-size: 11px;
color: #8c8c8c;
}
.metric-label {
font-size: 11px;
color: #8c8c8c;
margin-top: 2px;
}
/* 心率数值用橙色高亮 */
.metric-value.heart-rate {
color: #fa8c16;
}
/* ===== 锻炼趋势柱状图 ===== */
.trend-card {
background: #ffffff;
border-radius: 16px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
/* 柱状图容器 */
.bar-chart {
display: flex;
align-items: flex-end;
justify-content: space-around;
height: 100px;
padding: 0 8px;
margin-bottom: 8px;
}
/* 单根柱子组(柱体+标签) */
.bar-group {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
flex: 1;
}
/* 柱体数值标签 */
.bar-value-label {
font-size: 12px;
font-weight: 600;
color: #fa8c16;
}
/* 柱体本身 */
.bar {
width: 32px;
border-radius: 6px 6px 0 0;
background: #ffe7ba;
transition: height 0.3s ease;
}
/* 当前周(最后一根)高亮 */
.bar.current {
background: #fa8c16;
}
/* X轴周标签 */
.bar-x-labels {
display: flex;
justify-content: space-around;
padding: 0 8px;
}
.bar-x-label {
flex: 1;
text-align: center;
font-size: 11px;
color: #8c8c8c;
}
.bar-x-label.current {
color: #fa8c16;
font-weight: 600;
}
/* ===== 统计摘要三卡片 ===== */
.summary-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-bottom: 12px;
}
.summary-card {
background: #ffffff;
border-radius: 14px;
padding: 14px 10px;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.summary-icon {
font-size: 22px;
margin-bottom: 6px;
}
.summary-value {
font-size: 16px;
font-weight: 700;
color: #1a1a1a;
line-height: 1.2;
}
.summary-sub {
font-size: 11px;
color: #8c8c8c;
margin-top: 2px;
}
.summary-label {
font-size: 11px;
color: #8c8c8c;
margin-top: 4px;
}
/* ===== 底部安全区 ===== */
.bottom-safe {
height: 20px;
}
</style>
</head>
<body>
<!-- 手机外壳 -->
<div class="phone-shell">
<!-- 状态栏:时间 + WiFi + 电池 -->
<div class="status-bar">
<span class="time">9:41</span>
<div class="status-icons">
<!-- WiFi 图标 -->
<svg class="icon-wifi" viewBox="0 0 16 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 9.5C8.69 9.5 9.25 10.06 9.25 10.75S8.69 12 8 12s-1.25-.56-1.25-1.25S7.31 9.5 8 9.5z" fill="#1a1a1a"/>
<path d="M8 6.5c1.38 0 2.63.56 3.54 1.46L12.95 6.55A7.47 7.47 0 008 4.5c-1.93 0-3.69.73-5.01 1.93l1.41 1.41A5.47 5.47 0 018 6.5z" fill="#1a1a1a"/>
<path d="M8 2.5c2.76 0 5.26 1.12 7.07 2.93L16.5 4A9.97 9.97 0 008 .5C5.24.5 2.74 1.62.93 3.43L2.36 4.86A7.97 7.97 0 018 2.5z" fill="#1a1a1a" opacity="0.4"/>
</svg>
<!-- 电池图标 -->
<svg class="icon-battery" viewBox="0 0 25 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="21" height="11" rx="3.5" stroke="#1a1a1a" stroke-opacity="0.35"/>
<rect x="2" y="2" width="16" height="8" rx="2" fill="#1a1a1a"/>
<path d="M23 4v4a2 2 0 000-4z" fill="#1a1a1a" opacity="0.4"/>
</svg>
</div>
</div>
<!-- 导航栏:返回 + 标题"锻炼" -->
<div class="nav-bar">
<!-- 点击返回健康监测首页 -->
<a class="nav-back" onclick="location.href='index.html'">
<svg viewBox="0 0 10 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.5 1.5L1.5 8.5L8.5 15.5" stroke="#fa8c16" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</a>
<span class="nav-title">锻炼</span>
</div>
<!-- 滚动内容区 -->
<div class="scroll-content">
<!-- ① 本周概览卡片 -->
<div class="overview-card">
<div class="card-title">本周概览</div>
<div class="overview-stats">
<!-- 锻炼次数 -->
<div class="stat-item">
<div class="stat-value">3<span class="stat-unit"></span></div>
<div class="stat-label">锻炼次数</div>
</div>
<!-- 累计时长 -->
<div class="stat-item">
<div class="stat-value">2h<span class="stat-unit">15min</span></div>
<div class="stat-label">累计时长</div>
</div>
<!-- 消耗热量 -->
<div class="stat-item">
<div class="stat-value">680<span class="stat-unit">kcal</span></div>
<div class="stat-label">消耗热量</div>
</div>
<!-- 运动距离 -->
<div class="stat-item">
<div class="stat-value">4.2<span class="stat-unit">km</span></div>
<div class="stat-label">运动距离</div>
</div>
</div>
</div>
<!-- ② 时间维度 Tab:周 / 月 切换 -->
<div class="tab-bar">
<!-- 默认选中"周" -->
<div class="tab-item active" onclick="switchTab(this, 'week')"></div>
<div class="tab-item" onclick="switchTab(this, 'month')"></div>
</div>
<!-- ③ 本周锻炼记录列表 -->
<div class="section-title">本周锻炼记录</div>
<!-- 记录 a:户外跑步 -->
<div class="exercise-card">
<div class="exercise-header">
<div class="exercise-type">
<div class="exercise-icon">🏃</div>
<span class="exercise-name">户外跑步</span>
</div>
<span class="exercise-datetime">4月28日 18:30</span>
</div>
<!-- 四项指标:时长 / 距离 / 消耗 / 心率 -->
<div class="exercise-metrics">
<div class="metric-item">
<div class="metric-value">45<span class="metric-unit">min</span></div>
<div class="metric-label">时长</div>
</div>
<div class="metric-item">
<div class="metric-value">5.2<span class="metric-unit">km</span></div>
<div class="metric-label">距离</div>
</div>
<div class="metric-item">
<div class="metric-value">320<span class="metric-unit">kcal</span></div>
<div class="metric-label">消耗</div>
</div>
<div class="metric-item">
<!-- 心率用橙色高亮 -->
<div class="metric-value heart-rate">138<span class="metric-unit">bpm</span></div>
<div class="metric-label">平均心率</div>
</div>
</div>
</div>
<!-- 记录 b:骑行 -->
<div class="exercise-card">
<div class="exercise-header">
<div class="exercise-type">
<div class="exercise-icon">🚴</div>
<span class="exercise-name">骑行</span>
</div>
<span class="exercise-datetime">4月26日 07:15</span>
</div>
<div class="exercise-metrics">
<div class="metric-item">
<div class="metric-value">50<span class="metric-unit">min</span></div>
<div class="metric-label">时长</div>
</div>
<div class="metric-item">
<div class="metric-value">12.8<span class="metric-unit">km</span></div>
<div class="metric-label">距离</div>
</div>
<div class="metric-item">
<div class="metric-value">240<span class="metric-unit">kcal</span></div>
<div class="metric-label">消耗</div>
</div>
<div class="metric-item">
<div class="metric-value heart-rate">125<span class="metric-unit">bpm</span></div>
<div class="metric-label">平均心率</div>
</div>
</div>
</div>
<!-- 记录 c:游泳 -->
<div class="exercise-card">
<div class="exercise-header">
<div class="exercise-type">
<div class="exercise-icon">🏊</div>
<span class="exercise-name">游泳</span>
</div>
<span class="exercise-datetime">4月24日 19:00</span>
</div>
<div class="exercise-metrics">
<div class="metric-item">
<div class="metric-value">40<span class="metric-unit">min</span></div>
<div class="metric-label">时长</div>
</div>
<div class="metric-item">
<div class="metric-value">1.2<span class="metric-unit">km</span></div>
<div class="metric-label">距离</div>
</div>
<div class="metric-item">
<div class="metric-value">280<span class="metric-unit">kcal</span></div>
<div class="metric-label">消耗</div>
</div>
<div class="metric-item">
<div class="metric-value heart-rate">132<span class="metric-unit">bpm</span></div>
<div class="metric-label">平均心率</div>
</div>
</div>
</div>
<!-- ④ 锻炼趋势:近4周柱状图 -->
<div class="trend-card">
<div class="card-title">锻炼趋势</div>
<!-- 柱状图:数据 [2次, 4次, 3次, 3次],最大值4次对应100px高度 -->
<div class="bar-chart">
<!-- 第1周:2次,高度 = 2/4 * 80 = 40px -->
<div class="bar-group">
<span class="bar-value-label">2</span>
<div class="bar" style="height: 40px;"></div>
</div>
<!-- 第2周:4次,高度 = 4/4 * 80 = 80px -->
<div class="bar-group">
<span class="bar-value-label">4</span>
<div class="bar" style="height: 80px;"></div>
</div>
<!-- 第3周:3次,高度 = 3/4 * 80 = 60px -->
<div class="bar-group">
<span class="bar-value-label">3</span>
<div class="bar" style="height: 60px;"></div>
</div>
<!-- 第4周(本周):3次,高亮橙色 -->
<div class="bar-group">
<span class="bar-value-label">3</span>
<div class="bar current" style="height: 60px;"></div>
</div>
</div>
<!-- X轴标签 -->
<div class="bar-x-labels">
<span class="bar-x-label">第1周</span>
<span class="bar-x-label">第2周</span>
<span class="bar-x-label">第3周</span>
<span class="bar-x-label current">本周</span>
</div>
</div>
<!-- ⑤ 统计摘要:本月累计 / 总时长 / 总消耗 -->
<div class="section-title">本月统计</div>
<div class="summary-grid">
<!-- 本月累计次数 -->
<div class="summary-card">
<div class="summary-icon">🏅</div>
<div class="summary-value">12</div>
<div class="summary-sub"></div>
<div class="summary-label">本月累计</div>
</div>
<!-- 总时长 -->
<div class="summary-card">
<div class="summary-icon">⏱️</div>
<div class="summary-value">8h</div>
<div class="summary-sub">30min</div>
<div class="summary-label">总时长</div>
</div>
<!-- 总消耗 -->
<div class="summary-card">
<div class="summary-icon">🔥</div>
<div class="summary-value">2,450</div>
<div class="summary-sub">kcal</div>
<div class="summary-label">总消耗</div>
</div>
</div>
<!-- 底部安全区 -->
<div class="bottom-safe"></div>
</div><!-- /scroll-content -->
</div><!-- /phone-shell -->
<script>
/**
* Tab 切换:周 / 月
* 切换激活样式,实际数据切换可在此扩展
*/
function switchTab(el, period) {
// 移除所有 tab 的激活状态
document.querySelectorAll('.tab-item').forEach(function(item) {
item.classList.remove('active');
});
// 激活当前点击的 tab
el.classList.add('active');
// TODO: 根据 period 切换对应数据('week' | 'month'
}
</script>
</body>
</html>
@@ -0,0 +1,390 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>心率预警历史 - 健康监测 | 健康长庆原型</title>
<style>
/* 全局重置 */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #f0f2f5;
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
}
/* 手机外壳 */
.phone-shell {
width: 390px;
height: 844px;
background: #f5f7fa;
border-radius: 44px;
box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
}
/* 状态栏 */
.status-bar {
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
flex-shrink: 0;
background: #fff;
}
.status-bar .time { font-size: 15px; font-weight: 700; color: #333; }
.status-bar .icons { display: flex; gap: 6px; align-items: center; }
/* 导航栏 */
.nav-bar {
height: 44px;
background: #fff;
display: flex;
align-items: center;
padding: 0 16px;
flex-shrink: 0;
border-bottom: 1px solid #f0f0f0;
}
.nav-back {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.nav-title {
flex: 1;
text-align: center;
font-size: 17px;
font-weight: 600;
color: #1a1a1a;
margin-right: 28px;
}
/* 滚动内容区 */
.scroll-content {
flex: 1;
overflow-y: auto;
background: #f5f7fa;
}
.scroll-content::-webkit-scrollbar { display: none; }
/* 预警统计卡片 */
.stats-card {
margin: 12px 12px 0;
background: #fff;
border-radius: 14px;
padding: 20px 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
.stats-card__left { display: flex; flex-direction: column; align-items: flex-start; }
.stats-card__label { font-size: 13px; color: #999; margin-bottom: 6px; }
.stats-card__count-row { display: flex; align-items: baseline; gap: 4px; }
.stats-card__count { font-size: 40px; font-weight: 800; color: #ff4d4f; line-height: 1; }
.stats-card__unit { font-size: 14px; color: #ff4d4f; font-weight: 600; }
.stats-card__right { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
.stats-card__recent-label { font-size: 12px; color: #999; }
.stats-card__recent-time { font-size: 13px; font-weight: 600; color: #333; }
/* 预警图标装饰 */
.stats-card__icon {
width: 48px;
height: 48px;
background: #fff1f0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
}
/* 列表区块标题 */
.section-title {
font-size: 13px;
color: #999;
padding: 14px 16px 8px;
font-weight: 500;
}
/* 预警记录卡片 */
.alert-card {
margin: 0 12px 10px;
background: #fff;
border-radius: 14px;
padding: 14px 14px 14px 12px;
display: flex;
gap: 12px;
align-items: flex-start;
}
/* 左侧等级圆点 */
.alert-card__dot-col {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 3px;
gap: 4px;
}
.alert-card__dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.alert-card__dot--critical { background: #ff4d4f; box-shadow: 0 0 0 3px rgba(255,77,79,0.18); }
.alert-card__dot--warning { background: #faad14; box-shadow: 0 0 0 3px rgba(250,173,20,0.18); }
/* 卡片主体内容 */
.alert-card__body { flex: 1; min-width: 0; }
.alert-card__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.alert-card__time { font-size: 13px; color: #666; }
.alert-card__badge {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
flex-shrink: 0;
}
.alert-card__badge--critical { background: #fff1f0; color: #ff4d4f; }
.alert-card__badge--warning { background: #fffbe6; color: #d48806; }
/* 监测值 */
.alert-card__value-row {
display: flex;
align-items: baseline;
gap: 4px;
margin-bottom: 6px;
}
.alert-card__value { font-size: 24px; font-weight: 800; color: #1a1a1a; line-height: 1; }
.alert-card__value-unit { font-size: 13px; color: #999; }
/* 详情行 */
.alert-card__detail-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.alert-card__detail-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #999;
}
.alert-card__detail-item svg { flex-shrink: 0; }
/* 触发原因 */
.alert-card__reason {
margin-top: 8px;
font-size: 12px;
color: #bbb;
background: #fafafa;
border-radius: 6px;
padding: 6px 8px;
line-height: 1.5;
}
/* 底部安全区 */
.bottom-safe { height: 20px; flex-shrink: 0; }
</style>
</head>
<body>
<div class="phone-shell">
<!-- 状态栏 -->
<div class="status-bar">
<span class="time">9:41</span>
<div class="icons">
<!-- WiFi 图标 -->
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2">
<path d="M5 12.55a11 11 0 0 1 14.08 0"/>
<path d="M1.42 9a16 16 0 0 1 21.16 0"/>
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"/>
<circle cx="12" cy="20" r="1" fill="#333" stroke="none"/>
</svg>
<!-- 电池图标 -->
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2">
<rect x="1" y="6" width="18" height="12" rx="2"/>
<path d="M21 10v4"/>
<rect x="3" y="8" width="12" height="8" rx="1" fill="#333" stroke="none"/>
</svg>
</div>
</div>
<!-- 导航栏:返回心率详情页 -->
<div class="nav-bar">
<div class="nav-back" onclick="location.href='heart-rate.html'">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2" stroke-linecap="round">
<path d="M15 19l-7-7 7-7"/>
</svg>
</div>
<div class="nav-title">心率预警历史</div>
</div>
<!-- 滚动内容区 -->
<div class="scroll-content">
<!-- 预警统计卡片 -->
<div class="stats-card">
<div class="stats-card__left">
<div class="stats-card__label">本月预警</div>
<div class="stats-card__count-row">
<span class="stats-card__count">3</span>
<span class="stats-card__unit"></span>
</div>
</div>
<div class="stats-card__right">
<!-- 预警心形图标 -->
<div class="stats-card__icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" fill="#ff4d4f" stroke="#ff4d4f" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="stats-card__recent-label">最近预警时间</div>
<div class="stats-card__recent-time">2026-04-28 16:32</div>
</div>
</div>
<!-- 预警记录列表标题 -->
<div class="section-title">预警记录(共 3 条)</div>
<!-- 预警记录 1:严重 - 2026-04-28 -->
<div class="alert-card">
<!-- 左侧严重等级红点 -->
<div class="alert-card__dot-col">
<div class="alert-card__dot alert-card__dot--critical"></div>
</div>
<div class="alert-card__body">
<!-- 时间 + 等级标签 -->
<div class="alert-card__header">
<span class="alert-card__time">2026-04-28 16:32</span>
<span class="alert-card__badge alert-card__badge--critical">严重</span>
</div>
<!-- 监测值 -->
<div class="alert-card__value-row">
<span class="alert-card__value">142</span>
<span class="alert-card__value-unit">bpm</span>
</div>
<!-- 持续时长 + 心率标签 -->
<div class="alert-card__detail-row">
<!-- 时钟图标 + 持续时长 -->
<div class="alert-card__detail-item">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#bbb" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
<span>持续 5 分钟</span>
</div>
<!-- 心率标签 -->
<div class="alert-card__detail-item">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#bbb" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
<span>心率</span>
</div>
</div>
<!-- 触发原因 -->
<div class="alert-card__reason">触发原因:心率超过 140 bpm 阈值</div>
</div>
</div>
<!-- 预警记录 2:警告 - 2026-04-25 -->
<div class="alert-card">
<!-- 左侧警告等级黄点 -->
<div class="alert-card__dot-col">
<div class="alert-card__dot alert-card__dot--warning"></div>
</div>
<div class="alert-card__body">
<!-- 时间 + 等级标签 -->
<div class="alert-card__header">
<span class="alert-card__time">2026-04-25 14:18</span>
<span class="alert-card__badge alert-card__badge--warning">警告</span>
</div>
<!-- 监测值 -->
<div class="alert-card__value-row">
<span class="alert-card__value">128</span>
<span class="alert-card__value-unit">bpm</span>
</div>
<!-- 持续时长 + 心率标签 -->
<div class="alert-card__detail-row">
<div class="alert-card__detail-item">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#bbb" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
<span>持续 3 分钟</span>
</div>
<div class="alert-card__detail-item">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#bbb" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
<span>心率</span>
</div>
</div>
<!-- 触发原因 -->
<div class="alert-card__reason">触发原因:心率超过 120 bpm 阈值</div>
</div>
</div>
<!-- 预警记录 3:警告 - 2026-04-20 -->
<div class="alert-card">
<!-- 左侧警告等级黄点 -->
<div class="alert-card__dot-col">
<div class="alert-card__dot alert-card__dot--warning"></div>
</div>
<div class="alert-card__body">
<!-- 时间 + 等级标签 -->
<div class="alert-card__header">
<span class="alert-card__time">2026-04-20 09:45</span>
<span class="alert-card__badge alert-card__badge--warning">警告</span>
</div>
<!-- 监测值 -->
<div class="alert-card__value-row">
<span class="alert-card__value">126</span>
<span class="alert-card__value-unit">bpm</span>
</div>
<!-- 持续时长 + 心率标签 -->
<div class="alert-card__detail-row">
<div class="alert-card__detail-item">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#bbb" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
<span>持续 2 分钟</span>
</div>
<div class="alert-card__detail-item">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#bbb" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
<span>心率</span>
</div>
</div>
<!-- 触发原因 -->
<div class="alert-card__reason">触发原因:心率超过 120 bpm 阈值</div>
</div>
</div>
<!-- 底部安全区 -->
<div class="bottom-safe"></div>
</div><!-- /scroll-content -->
</div><!-- /phone-shell -->
</body>
</html>
+316
View File
@@ -0,0 +1,316 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>心率详情 - 健康监测 | 健康长庆原型</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #f0f2f5; display: flex; justify-content: center; align-items: flex-start; min-height: 100vh; padding: 20px; font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif; }
.phone-shell { width: 390px; height: 844px; background: #f5f7fa; border-radius: 44px; box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0; overflow: hidden; position: relative; display: flex; flex-direction: column; }
.status-bar { height: 44px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; flex-shrink: 0; background: #fff; }
.status-bar .time { font-size: 15px; font-weight: 700; color: #333; }
.status-bar .icons { display: flex; gap: 6px; align-items: center; }
.nav-bar { height: 44px; background: #fff; display: flex; align-items: center; padding: 0 16px; flex-shrink: 0; border-bottom: 1px solid #f0f0f0; }
.nav-back { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.nav-title { flex: 1; text-align: center; font-size: 17px; font-weight: 600; color: #1a1a1a; margin-right: 28px; }
.scroll-content { flex: 1; overflow-y: auto; background: #f5f7fa; }
.scroll-content::-webkit-scrollbar { display: none; }
/* 主题色(心率红) */
:root { --theme: #ff7a8a; --theme2:#ffb3bd; }
.top-panel { background: #fff; padding-top: 2px; }
.time-tabs { display: flex; justify-content: center; gap: 42px; padding: 2px 0 8px; }
.time-tab { position: relative; font-size: 18px; color: #444; cursor: pointer; padding: 0 2px 6px; user-select: none; }
.time-tab.active { color: var(--theme); }
.time-tab.active::after { content: ''; position: absolute; left: 0; right: 0; bottom: 0; margin: auto; width: 22px; height: 3px; border-radius: 2px; background: var(--theme); }
.summary-area { text-align: center; padding: 12px 24px 6px; }
.date-row { display: inline-flex; align-items: center; gap: 6px; font-size: 16px; color: #666; font-weight: 500; }
.range-text { margin-top: 4px; font-size: 16px; color: #666; }
.main-row { margin-top: 2px; display: inline-flex; align-items: baseline; gap: 4px; }
.main-row__value { font-size: 48px; line-height: 1; font-weight: 500; color: #1f1f1f; letter-spacing: -1px; }
.main-row__unit { font-size: 18px; color: #333; }
.main-badge { display: inline-flex; align-items: center; justify-content: center; margin-top: 6px; padding: 3px 12px; border-radius: 18px; font-size: 12px; font-weight: 600; background: #f6ffed; color: #52c41a; }
.chart-wrap { position: relative; background: #fff; padding: 6px 16px 0; }
.chart-box { position: relative; height: 160px; }
.chart-svg { width: 100%; height: 100%; display: block; }
.chart-grid line { stroke: #ececec; stroke-width: 1; }
.chart-path { fill: none; stroke: var(--theme); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.chart-dot { fill: var(--theme); }
.y-axis-labels { position: absolute; top: 6px; right: 0; width: 56px; height: 208px; pointer-events: none; }
.y-axis-label { position: absolute; right: 0; transform: translateY(50%); font-size: 12px; color: #666; }
.x-axis { display: grid; gap: 0; padding: 8px 14px 0 0; font-size: 12px; color: #666; }
.x-axis span { text-align: center; white-space: nowrap; }
.x-axis--dense { font-size: 10px; }
.timeline-knob-row { position: relative; margin: 0; height: 24px; }
.timeline-track { position: absolute; left: 0; right: 0; top: 10px; height: 1px; background: #e8e8e8; }
.timeline-knob { position: absolute; right: 16px; top: -4px; width: 24px; height: 24px; border-radius: 50%; background: #fff; box-shadow: 0 2px 10px rgba(0,0,0,0.12); }
.card { margin: 8px 12px 0; background: #fff; border-radius: 22px; padding: 14px 12px; display: flex; gap: 0; }
.metric-row { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 6px 6px; }
.metric-left__label { font-size: 12px; color: #777; margin-bottom: 4px; }
.metric-left__value { display: flex; align-items: baseline; gap: 2px; }
.metric-left__num { font-size: 16px; font-weight: 600; color: #222; }
.metric-left__unit { font-size: 12px; color: #777; }
.metric-right { font-size: 12px; color: #777; text-align: center; }
.metric-split { width: 1px; background: #ececec; margin: 0; }
.alert-btn { margin: 8px 12px 0; border: 1.5px solid var(--theme); border-radius: 12px; padding: 11px; text-align: center; font-size: 13px; color: var(--theme); font-weight: 600; cursor: pointer; background: #fff; display: flex; align-items: center; justify-content: center; gap: 6px; }
.foot-note { padding: 10px 0 12px; text-align: center; font-size: 11px; color: #b3b3b3; }
.bottom-safe { height: 12px; flex-shrink: 0; }
</style>
</head>
<body>
<div class="phone-shell">
<div class="status-bar">
<span class="time">9:41</span>
<div class="icons">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<div class="nav-bar">
<div class="nav-back" onclick="location.href='index.html'">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2" stroke-linecap="round"><path d="M15 19l-7-7 7-7"/></svg>
</div>
<div class="nav-title">心率</div>
</div>
<div class="scroll-content">
<div class="top-panel">
<div class="time-tabs">
<div class="time-tab active" data-mode="day"></div>
<div class="time-tab" data-mode="week"></div>
<div class="time-tab" data-mode="month"></div>
<div class="time-tab" data-mode="year"></div>
</div>
<div class="summary-area">
<div class="date-row">
<span id="dateText">4月30日 周四</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="#666"><path d="M7 10l5 5 5-5z"/></svg>
</div>
<div class="range-text" id="rangeText">00:00-23:59</div>
<div class="main-row">
<span class="main-row__value" id="mainValue">72</span>
<span class="main-row__unit" id="mainUnit">bpm</span>
</div>
<div class="main-badge" id="statusBadge">正常</div>
</div>
<div class="chart-wrap">
<div class="chart-box">
<svg class="chart-svg" id="chart" viewBox="0 0 358 258" preserveAspectRatio="none">
<g class="chart-grid" id="chartGrid"></g>
<path id="line" class="chart-path"></path>
<circle id="dot" class="chart-dot" r="3.2"></circle>
</svg>
<div class="y-axis-labels" id="yAxisLabels"></div>
</div>
<div class="x-axis" id="xAxis"></div>
<div class="timeline-knob-row">
<div class="timeline-track"></div>
<div class="timeline-knob"></div>
</div>
</div>
</div>
<div class="card">
<div class="metric-row">
<div class="metric-left">
<div class="metric-left__label">最高</div>
<div class="metric-left__value"><span class="metric-left__num" id="maxValue">126</span><span class="metric-left__unit">bpm</span></div>
</div>
<div class="metric-right" id="maxHint">14:20</div>
</div>
<div class="metric-split"></div>
<div class="metric-row">
<div class="metric-left">
<div class="metric-left__label">最低</div>
<div class="metric-left__value"><span class="metric-left__num" id="minValue">68</span><span class="metric-left__unit">bpm</span></div>
</div>
<div class="metric-right" id="minHint">03:40</div>
</div>
<div class="metric-split"></div>
<div class="metric-row">
<div class="metric-left">
<div class="metric-left__label">平均</div>
<div class="metric-left__value"><span class="metric-left__num" id="avgValue">82</span><span class="metric-left__unit">bpm</span></div>
</div>
<div class="metric-right" id="avgHint">全天</div>
</div>
</div>
<div class="alert-btn" onclick="location.href='heart-rate-alerts.html'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--theme)" stroke-width="2"><path d="M12 9v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
查看预警历史
</div>
<div class="foot-note">以上数据为原型示例,实际以设备同步为准</div>
<div class="bottom-safe"></div>
</div>
</div>
<script>
var dataMap = {
day: {
dateText: '4月30日 周四',
rangeText: '00:00-23:59',
unit: 'bpm',
decimals: 0,
points: [84,82,80,78,76,74,72,70,68,75,79,83,88,96,102,126,108,94,90,87,85,84,83,72],
totalMode: 'last',
yTicks: [140, 110, 80, 60, 40],
xLabels: ['00:00','06:00','12:00','18:00','24:00'],
status: '正常',
resting: 62,
max: { v:126, hint:'14:20' },
min: { v:68, hint:'03:40' },
avg: { v:82, hint:'全天' }
},
week: {
dateText: '4月27日-5月3日',
rangeText: '周一-周日',
unit: 'bpm',
decimals: 0,
points: [78, 80, 82, 79, 83, 81, 80],
totalMode: 'avg',
yTicks: [120, 100, 80, 60, 40],
xLabels: ['周一','周二','周三','周四','周五','周六','周日'],
status: '正常',
resting: 63,
max: { v:118, hint:'周三 18:10' },
min: { v:61, hint:'周二 03:50' },
avg: { v:80, hint:'本周' }
},
month: {
dateText: '2026年4月',
rangeText: '4月1日-4月30日',
unit: 'bpm',
decimals: 0,
points: [79,81,80,82,83,78,80,81,82,79,80,81,82,83,79,80,81,82,80,79,81,82,80,79,81,82,80,79,81,80],
totalMode: 'avg',
yTicks: [120, 100, 80, 60, 40],
xLabels: ['1日','7日','14日','21日','30日'],
status: '正常',
resting: 64,
max: { v:126, hint:'04-15 14:20' },
min: { v:58, hint:'04-08 03:40' },
avg: { v:80, hint:'本月' }
},
year: {
dateText: '2026年',
rangeText: '1月-12月',
unit: 'bpm',
decimals: 0,
points: [79,80,81,80,79,78,79,80,81,80,79,80],
totalMode: 'avg',
yTicks: [120, 100, 80, 60, 40],
xLabels: ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'],
status: '正常',
resting: 64,
max: { v:132, hint:'03月' },
min: { v:57, hint:'02月' },
avg: { v:80, hint:'全年' }
}
};
function avg(arr) {
if (!arr.length) return 0;
var sum = arr.reduce(function(s, v){ return s + v; }, 0);
return sum / arr.length;
}
function getValue(cfg) {
if (cfg.totalMode === 'last') return cfg.points[cfg.points.length - 1];
if (cfg.totalMode === 'avg') return avg(cfg.points);
if (cfg.totalMode === 'max') return Math.max.apply(null, cfg.points);
if (cfg.totalMode === 'min') return Math.min.apply(null, cfg.points);
return cfg.points[cfg.points.length - 1];
}
function formatValue(val, decimals) {
if (typeof val !== 'number') return String(val);
if (decimals != null) return val.toFixed(decimals);
return String(val);
}
function renderChart(cfg) {
var chartWidth = 330;
var chartHeight = 208;
var startY = 22;
var stepX = chartWidth / (cfg.points.length - 1 || 1);
var maxY = cfg.yTicks[0];
var grid = document.getElementById('chartGrid');
var yAxis = document.getElementById('yAxisLabels');
var xAxis = document.getElementById('xAxis');
var lineEl = document.getElementById('line');
var dotEl = document.getElementById('dot');
grid.innerHTML = '';
cfg.yTicks.forEach(function(_, index) {
var y = startY + (chartHeight / (cfg.yTicks.length - 1)) * index;
grid.innerHTML += '<line x1="0" y1="' + y.toFixed(1) + '" x2="330" y2="' + y.toFixed(1) + '"></line>';
});
yAxis.innerHTML = cfg.yTicks.map(function(tick, index) {
return '<span class="y-axis-label" style="top:' + (index * 52) + 'px">' + formatValue(tick, 0) + '</span>';
}).join('');
var pts = cfg.points.map(function(v, i) {
var x = i * stepX;
var y = startY + chartHeight - (v / maxY) * chartHeight;
return { x: x, y: y };
});
var d = pts.map(function(p, index) {
return (index === 0 ? 'M' : 'L') + p.x.toFixed(1) + ',' + p.y.toFixed(1);
}).join(' ');
lineEl.setAttribute('d', d);
var last = pts[pts.length - 1];
dotEl.setAttribute('cx', last.x.toFixed(1));
dotEl.setAttribute('cy', last.y.toFixed(1));
xAxis.className = 'x-axis' + (cfg.xLabels.length > 7 ? ' x-axis--dense' : '');
xAxis.style.gridTemplateColumns = 'repeat(' + cfg.xLabels.length + ', 1fr)';
xAxis.innerHTML = cfg.xLabels.map(function(label) {
return '<span>' + label + '</span>';
}).join('');
}
function renderMode(mode) {
var cfg = dataMap[mode];
var value = getValue(cfg);
document.getElementById('dateText').textContent = cfg.dateText;
document.getElementById('rangeText').textContent = cfg.rangeText;
document.getElementById('mainValue').textContent = formatValue(value, cfg.decimals);
document.getElementById('mainUnit').textContent = cfg.unit;
document.getElementById('statusBadge').textContent = cfg.status;
document.getElementById('maxValue').textContent = formatValue(cfg.max.v, 0);
document.getElementById('maxHint').textContent = cfg.max.hint;
document.getElementById('minValue').textContent = formatValue(cfg.min.v, 0);
document.getElementById('minHint').textContent = cfg.min.hint;
document.getElementById('avgValue').textContent = formatValue(cfg.avg.v, 0);
document.getElementById('avgHint').textContent = cfg.avg.hint;
renderChart(cfg);
}
document.querySelectorAll('.time-tab').forEach(function(tab) {
tab.onclick = function() {
document.querySelectorAll('.time-tab').forEach(function(item) { item.classList.remove('active'); });
tab.classList.add('active');
renderMode(tab.getAttribute('data-mode'));
};
});
renderMode('day');
</script>
</body>
</html>
+278
View File
@@ -0,0 +1,278 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>健康监测 - 员工端 | 健康长庆原型</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #f0f2f5; display: flex; justify-content: center; align-items: flex-start; min-height: 100vh; padding: 20px; font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif; }
.phone-shell { width: 390px; height: 844px; background: #f5f7fa; border-radius: 44px; box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0; overflow: hidden; position: relative; display: flex; flex-direction: column; }
.status-bar { height: 44px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; flex-shrink: 0; background: #fff; }
.status-bar .time { font-size: 15px; font-weight: 700; color: #333; }
.status-bar .icons { display: flex; gap: 6px; align-items: center; }
/* 导航栏 */
.nav-bar { height: 44px; background: #fff; display: flex; align-items: center; padding: 0 16px; flex-shrink: 0; border-bottom: 1px solid #f0f0f0; }
.nav-back { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.nav-title { flex: 1; text-align: center; font-size: 17px; font-weight: 600; color: #1a1a1a; margin-right: 28px; }
.scroll-content { flex: 1; overflow-y: auto; background: #f5f7fa; }
.scroll-content::-webkit-scrollbar { display: none; }
/* 设备状态 */
.device-bar { margin: 12px 12px 0; background: linear-gradient(135deg, #13c2c2 0%, #0d9b9b 100%); border-radius: 12px; padding: 14px 16px; display: flex; align-items: center; gap: 12px; color: #fff; }
.device-icon { width: 44px; height: 44px; background: rgba(255,255,255,0.2); border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 24px; flex-shrink: 0; }
.device-info { flex: 1; }
.device-name { font-size: 15px; font-weight: 600; }
.device-meta { font-size: 12px; opacity: 0.85; margin-top: 2px; }
.device-battery { display: flex; align-items: center; gap: 4px; font-size: 13px; font-weight: 600; }
.battery-icon { width: 22px; height: 12px; border: 1.5px solid #fff; border-radius: 2px; position: relative; }
.battery-icon::after { content: ''; position: absolute; right: -4px; top: 2.5px; width: 2px; height: 5px; background: #fff; border-radius: 0 1px 1px 0; }
.battery-fill { height: 100%; background: #fff; border-radius: 1px; }
/* 监测卡片网格 */
.monitor-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 12px; }
.monitor-card { background: #fff; border-radius: 12px; padding: 16px; cursor: pointer; position: relative; transition: transform 0.15s; }
.monitor-card:active { transform: scale(0.97); }
.monitor-card--full { grid-column: 1 / -1; }
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.card-header__left { display: flex; align-items: center; min-width: 0; flex: 1; }
.card-header__right { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.card-icon { width: 32px; height: 32px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0; }
.card-label { font-size: 13px; color: #666; margin-left: 8px; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.card-meta { font-size: 12px; color: #999; }
.card-alert-badge { width: 18px; height: 18px; background: #ff4d4f; border-radius: 50%; font-size: 10px; color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 700; }
.card-arrow { width: 16px; height: 16px; display: flex; align-items: center; justify-content: center; }
.card-arrow svg { width: 16px; height: 16px; display: block; }
.card-sync { font-size: 10px; color: #bbb; margin-top: 8px; }
.card-value { font-size: 28px; font-weight: 800; color: #1a1a1a; line-height: 1.1; }
.card-unit { font-size: 13px; color: #999; font-weight: 400; margin-left: 2px; }
.card-status { display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: 4px; margin-top: 6px; }
.card-status--normal { color: #52c41a; background: #f6ffed; }
.card-status--warn { color: #faad14; background: #fffbe6; }
.card-status--danger { color: #ff4d4f; background: #fff2f0; }
.card-sub { font-size: 11px; color: #999; margin-top: 6px; }
.card-progress { margin-top: 8px; }
.card-progress__bar { height: 6px; background: #f0f0f0; border-radius: 3px; overflow: hidden; }
.card-progress__fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
.card-progress__text { font-size: 11px; color: #999; margin-top: 4px; }
/* 睡眠分段 */
.sleep-segments { display: flex; gap: 4px; margin-top: 8px; height: 6px; border-radius: 3px; overflow: hidden; }
.sleep-seg { height: 100%; border-radius: 3px; }
.sleep-seg--deep { background: #1890ff; }
.sleep-seg--light { background: #91d5ff; }
.sleep-seg--rem { background: #b37feb; }
.sleep-seg--awake { background: #ffd591; }
.sleep-legend { display: flex; gap: 8px; margin-top: 6px; flex-wrap: wrap; }
.sleep-legend__item { display: flex; align-items: center; gap: 3px; font-size: 10px; color: #999; }
.sleep-legend__dot { width: 6px; height: 6px; border-radius: 50%; }
/* 锻炼卡片 */
.exercise-stats { display: flex; gap: 12px; margin-top: 8px; }
.exercise-stat { flex: 1; text-align: center; background: #f5f7fa; border-radius: 8px; padding: 8px 4px; }
.exercise-stat__val { font-size: 16px; font-weight: 700; color: #1a1a1a; }
.exercise-stat__label { font-size: 10px; color: #999; margin-top: 2px; }
/* 预警入口 */
.card-alert-link { display: flex; align-items: center; justify-content: center; gap: 4px; font-size: 11px; color: #ff4d4f; margin-top: 8px; padding-top: 8px; border-top: 1px solid #f5f5f5; cursor: pointer; }
/* 底部安全区 */
.bottom-safe { height: 20px; flex-shrink: 0; }
</style>
</head>
<body>
<div class="phone-shell">
<div class="status-bar">
<span class="time">9:41</span>
<div class="icons">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<div class="nav-bar">
<div class="nav-back" onclick="history.back()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2" stroke-linecap="round"><path d="M15 19l-7-7 7-7"/></svg>
</div>
<div class="nav-title">健康监测</div>
</div>
<div class="scroll-content">
<!-- 设备连接状态 -->
<div class="device-bar">
<div class="device-icon"></div>
<div class="device-info">
<div class="device-name">华为 WATCH GT5 Pro</div>
<div class="device-meta">SF55996622586 · 已连接</div>
</div>
<!-- <div class="device-battery">-->
<!-- <div class="battery-icon"><div class="battery-fill" style="width:72%"></div></div>-->
<!-- 72%-->
<!-- </div>-->
</div>
<!-- 监测卡片网格 -->
<div class="monitor-grid">
<!-- 步数 -->
<div class="monitor-card" onclick="location.href='steps.html'">
<div class="card-header">
<div class="card-header__left">
<div class="card-icon" style="background:#e6f7ff">🏃</div>
<span class="card-label">步数</span>
</div>
<div class="card-header__right">
<div class="card-arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="#bbb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
</div>
</div>
</div>
<div class="card-value">8,562<span class="card-unit"></span></div>
<div class="card-progress">
<div class="card-progress__text">相当于步行5.2公里</div>
</div>
<div class="card-sync">同步至 04-30 09:38</div>
</div>
<!-- 睡眠 -->
<div class="monitor-card" onclick="location.href='sleep.html'">
<div class="card-header">
<div class="card-header__left">
<div class="card-icon" style="background:#f0e6ff">🌙</div>
<span class="card-label">睡眠</span>
</div>
<div class="card-header__right">
<div class="card-arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="#bbb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
</div>
</div>
</div>
<div class="card-value">7<span class="card-unit">h</span>32<span class="card-unit">min</span></div>
<div class="card-progress__text">平均睡眠8小时57分</div>
<div class="card-sync">同步至 04-30 07:05</div>
<!-- <div class="sleep-segments">-->
<!-- <div class="sleep-seg sleep-seg&#45;&#45;deep" style="width:28%"></div>-->
<!-- <div class="sleep-seg sleep-seg&#45;&#45;light" style="width:42%"></div>-->
<!-- <div class="sleep-seg sleep-seg&#45;&#45;rem" style="width:20%"></div>-->
<!-- <div class="sleep-seg sleep-seg&#45;&#45;awake" style="width:10%"></div>-->
<!-- </div>-->
<!-- <div class="sleep-legend">-->
<!-- <div class="sleep-legend__item"><div class="sleep-legend__dot" style="background:#1890ff"></div>深睡</div>-->
<!-- <div class="sleep-legend__item"><div class="sleep-legend__dot" style="background:#91d5ff"></div>浅睡</div>-->
<!-- <div class="sleep-legend__item"><div class="sleep-legend__dot" style="background:#b37feb"></div>REM</div>-->
<!-- <div class="sleep-legend__item"><div class="sleep-legend__dot" style="background:#ffd591"></div>清醒</div>-->
<!-- </div>-->
</div>
<!-- 心率 -->
<div class="monitor-card" onclick="location.href='heart-rate.html'">
<div class="card-header">
<div class="card-header__left">
<div class="card-icon" style="background:#fff1f0">❤️</div>
<span class="card-label">心率</span>
</div>
<div class="card-header__right">
<div class="card-arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="#bbb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
</div>
</div>
</div>
<div class="card-value">72<span class="card-unit">bpm</span></div>
<div class="card-sub">范围:89~120</div>
<div class="card-sync">同步至 04-30 09:37</div>
<div class="card-alert-link" onclick="event.stopPropagation();location.href='heart-rate-alerts.html'">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#ff4d4f" stroke-width="2"><path d="M12 9v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
查看预警历史
</div>
</div>
<!-- 体温 -->
<div class="monitor-card" onclick="location.href='temperature.html'">
<div class="card-header">
<div class="card-header__left">
<div class="card-icon" style="background:#f9f0ff">🌡️</div>
<span class="card-label">体温</span>
</div>
<div class="card-header__right">
<div class="card-arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="#bbb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
</div>
</div>
</div>
<div class="card-value">36.5<span class="card-unit">°C</span></div>
<div class="card-sub">范围 36.2~37.1°C</div>
<div class="card-sync">同步至 04-30 09:30</div>
<div class="card-alert-link" onclick="event.stopPropagation();location.href='temperature-alerts.html'">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#ff4d4f" stroke-width="2"><path d="M12 9v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
查看预警历史
</div>
</div>
<!-- 血氧 -->
<div class="monitor-card" onclick="location.href='blood-oxygen.html'">
<div class="card-header">
<div class="card-header__left">
<div class="card-icon" style="background:#f6ffed">💧</div>
<span class="card-label">血氧</span>
</div>
<div class="card-header__right">
<div class="card-arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="#bbb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
</div>
</div>
</div>
<div class="card-value">98<span class="card-unit">%</span></div>
<div class="card-sub">范围 95~99%</div>
<div class="card-sync">同步至 04-30 09:35</div>
<div class="card-alert-link" onclick="event.stopPropagation();location.href='blood-oxygen-alerts.html'">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#ff4d4f" stroke-width="2"><path d="M12 9v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
查看预警历史
</div>
</div>
<!-- 压力 -->
<div class="monitor-card" onclick="location.href='stress.html'">
<div class="card-header">
<div class="card-header__left">
<div class="card-icon" style="background:#fffbe6">🧠</div>
<span class="card-label">压力</span>
</div>
<div class="card-header__right">
<div class="card-arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="#bbb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
</div>
</div>
</div>
<div class="card-value">42</div>
<div class="card-sub">范围 18~82</div>
<div class="card-sync">同步至 04-30 09:36</div>
<div class="card-alert-link" onclick="event.stopPropagation();location.href='stress-alerts.html'">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#ff4d4f" stroke-width="2"><path d="M12 9v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
查看预警历史
</div>
</div>
<!-- 锻炼 -->
<!-- <div class="monitor-card monitor-card&#45;&#45;full" onclick="location.href='exercise.html'">-->
<!-- <div class="card-header">-->
<!-- <div class="card-header__left">-->
<!-- <div class="card-icon" style="background:#fff7e6">🏋️</div>-->
<!-- <span class="card-label">锻炼</span>-->
<!-- </div>-->
<!-- <div class="card-header__right">-->
<!-- <span class="card-meta">本周</span>-->
<!-- <div class="card-arrow">-->
<!-- <svg viewBox="0 0 24 24" fill="none" stroke="#bbb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!-- <div class="exercise-stats">-->
<!-- <div class="exercise-stat">-->
<!-- <div class="exercise-stat__val">3<span style="font-size:12px;color:#999;font-weight:400"> 次</span></div>-->
<!-- <div class="exercise-stat__label">锻炼次数</div>-->
<!-- </div>-->
<!-- <div class="exercise-stat">-->
<!-- <div class="exercise-stat__val">2h15<span style="font-size:12px;color:#999;font-weight:400"> min</span></div>-->
<!-- <div class="exercise-stat__label">累计时长</div>-->
<!-- </div>-->
<!-- <div class="exercise-stat">-->
<!-- <div class="exercise-stat__val">680<span style="font-size:12px;color:#999;font-weight:400"> kcal</span></div>-->
<!-- <div class="exercise-stat__label">消耗热量</div>-->
<!-- </div>-->
<!-- <div class="exercise-stat">-->
<!-- <div class="exercise-stat__val">4.2<span style="font-size:12px;color:#999;font-weight:400"> km</span></div>-->
<!-- <div class="exercise-stat__label">运动距离</div>-->
<!-- </div>-->
<!-- </div>-->
<!-- <div class="card-sync">同步至 04-30 09:20</div>-->
<!-- </div>-->
</div>
</div>
<div class="bottom-safe"></div>
</div>
</body>
</html>
+309
View File
@@ -0,0 +1,309 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>睡眠详情 - 健康监测 | 健康长庆原型</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #f0f2f5; display: flex; justify-content: center; align-items: flex-start; min-height: 100vh; padding: 20px; font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif; }
.phone-shell { width: 390px; height: 844px; background: #f5f7fa; border-radius: 44px; box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0; overflow: hidden; position: relative; display: flex; flex-direction: column; }
/* 状态栏 */
.status-bar { height: 44px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; flex-shrink: 0; background: #fff; }
.status-bar .time { font-size: 15px; font-weight: 700; color: #333; }
.status-bar .icons { display: flex; gap: 6px; align-items: center; }
/* 导航栏 */
.nav-bar { height: 44px; background: #fff; display: flex; align-items: center; padding: 0 16px; flex-shrink: 0; border-bottom: 1px solid #f0f0f0; }
.nav-back { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.nav-title { flex: 1; text-align: center; font-size: 17px; font-weight: 600; color: #1a1a1a; margin-right: 28px; }
/* 滚动内容区 */
.scroll-content { flex: 1; overflow-y: auto; background: #f5f7fa; }
.scroll-content::-webkit-scrollbar { display: none; }
/* 核心数据区 */
.hero { background: #fff; padding: 24px 16px 20px; text-align: center; }
/* 评分圆环 */
.hero__ring { width: 160px; height: 160px; margin: 0 auto 12px; position: relative; }
.hero__ring svg { width: 100%; height: 100%; transform: rotate(-90deg); }
.hero__ring-center { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); text-align: center; }
.hero__score { font-size: 38px; font-weight: 800; color: #1a1a1a; line-height: 1; }
.hero__score-label { font-size: 12px; color: #999; margin-top: 2px; }
/* 睡眠时长 */
.hero__duration { font-size: 22px; font-weight: 700; color: #1a1a1a; margin-bottom: 4px; }
.hero__duration span { font-size: 14px; font-weight: 400; color: #666; }
/* 入睡/起床时间 */
.hero-times { display: flex; justify-content: center; gap: 40px; margin-top: 14px; }
.hero-time { text-align: center; }
.hero-time__val { font-size: 20px; font-weight: 700; color: #1a1a1a; }
.hero-time__label { font-size: 11px; color: #999; margin-top: 2px; }
.hero-time__icon { font-size: 16px; margin-bottom: 2px; }
/* 分隔线 */
.divider { width: 1px; height: 40px; background: #f0f0f0; align-self: center; }
/* 睡眠分段条形图区 */
.section { margin: 10px 12px 0; background: #fff; border-radius: 12px; padding: 16px; }
.section-title { font-size: 14px; font-weight: 600; color: #1a1a1a; margin-bottom: 14px; }
/* 横向分段条 */
.stage-bar-wrap { display: flex; border-radius: 6px; overflow: hidden; height: 14px; margin-bottom: 14px; }
.stage-bar-seg { height: 100%; transition: width 0.4s; }
/* 图例列表 */
.stage-legend { display: flex; flex-direction: column; gap: 8px; }
.stage-item { display: flex; align-items: center; gap: 8px; }
.stage-dot { width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; }
.stage-name { font-size: 12px; color: #666; width: 36px; }
.stage-bar-bg { flex: 1; height: 6px; background: #f5f7fa; border-radius: 3px; overflow: hidden; }
.stage-bar-fill { height: 100%; border-radius: 3px; }
.stage-pct { font-size: 12px; color: #999; width: 30px; text-align: right; }
.stage-dur { font-size: 12px; font-weight: 600; color: #1a1a1a; width: 56px; text-align: right; }
/* 时间维度Tab */
.time-tabs { display: flex; margin: 10px 12px 0; background: #fff; border-radius: 10px; overflow: hidden; }
.time-tab { flex: 1; text-align: center; padding: 10px 0; font-size: 13px; color: #666; cursor: pointer; }
.time-tab.active { background: #1890ff; color: #fff; border-radius: 10px; font-weight: 600; }
/* 睡眠深度曲线 */
.depth-chart { width: 100%; height: 80px; }
.depth-axis { display: flex; justify-content: space-between; font-size: 10px; color: #bbb; margin-top: 4px; }
/* 近7天趋势 */
.trend-chart { width: 100%; height: 100px; }
.trend-axis { display: flex; justify-content: space-between; font-size: 10px; color: #bbb; margin-top: 4px; }
/* 统计摘要 */
.summary { display: flex; gap: 8px; margin: 10px 12px 0; }
.summary-card { flex: 1; background: #fff; border-radius: 12px; padding: 14px 8px; text-align: center; }
.summary-card__val { font-size: 18px; font-weight: 700; color: #1a1a1a; line-height: 1.2; }
.summary-card__unit { font-size: 11px; color: #999; }
.summary-card__label { font-size: 11px; color: #999; margin-top: 4px; }
/* 底部安全区 */
.bottom-safe { height: 20px; flex-shrink: 0; }
</style>
</head>
<body>
<div class="phone-shell">
<!-- 状态栏 -->
<div class="status-bar">
<span class="time">9:41</span>
<div class="icons">
<!-- WiFi图标 -->
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<!-- 电池图标 -->
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<!-- 导航栏 -->
<div class="nav-bar">
<div class="nav-back" onclick="location.href='index.html'">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2" stroke-linecap="round"><path d="M15 19l-7-7 7-7"/></svg>
</div>
<div class="nav-title">睡眠</div>
</div>
<!-- 滚动内容区 -->
<div class="scroll-content">
<!-- 核心数据区:评分圆环 + 时长 + 入睡/起床 -->
<div class="hero">
<!-- 睡眠评分圆环 -->
<div class="hero__ring">
<svg viewBox="0 0 160 160">
<defs>
<linearGradient id="sleepGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#1890ff"/>
<stop offset="100%" stop-color="#b37feb"/>
</linearGradient>
</defs>
<!-- 背景圆环 -->
<circle cx="80" cy="80" r="70" fill="none" stroke="#f0f0f0" stroke-width="10"/>
<!-- 评分圆弧:85分 = 85% 周长,周长=2π×70≈439.885%≈373.8,偏移=439.8-373.8=66 -->
<circle cx="80" cy="80" r="70" fill="none" stroke="url(#sleepGrad)" stroke-width="10" stroke-linecap="round" stroke-dasharray="439.8" stroke-dashoffset="66"/>
</svg>
<div class="hero__ring-center">
<div class="hero__score">85</div>
<div class="hero__score-label">睡眠评分</div>
</div>
</div>
<!-- 睡眠时长 -->
<div class="hero__duration">7<span>h</span> 32<span>min</span></div>
<!-- 入睡 / 起床时间 -->
<div class="hero-times">
<div class="hero-time">
<div class="hero-time__icon">🌙</div>
<div class="hero-time__val">23:12</div>
<div class="hero-time__label">入睡</div>
</div>
<div class="divider"></div>
<div class="hero-time">
<div class="hero-time__icon">☀️</div>
<div class="hero-time__val">06:44</div>
<div class="hero-time__label">起床</div>
</div>
</div>
</div>
<!-- 睡眠分段条形图 -->
<div class="section">
<div class="section-title">睡眠阶段</div>
<!-- 横向分段总条 -->
<div class="stage-bar-wrap">
<!-- 深睡 28% -->
<div class="stage-bar-seg" style="width:28%;background:#1890ff;"></div>
<!-- 浅睡 42% -->
<div class="stage-bar-seg" style="width:42%;background:#91d5ff;"></div>
<!-- REM 20% -->
<div class="stage-bar-seg" style="width:20%;background:#b37feb;"></div>
<!-- 清醒 10% -->
<div class="stage-bar-seg" style="width:10%;background:#ffd591;"></div>
</div>
<!-- 各阶段图例 + 进度条 -->
<div class="stage-legend">
<!-- 深睡 -->
<div class="stage-item">
<div class="stage-dot" style="background:#1890ff;"></div>
<div class="stage-name">深睡</div>
<div class="stage-bar-bg"><div class="stage-bar-fill" style="width:28%;background:#1890ff;"></div></div>
<div class="stage-pct">28%</div>
<div class="stage-dur">2h 06min</div>
</div>
<!-- 浅睡 -->
<div class="stage-item">
<div class="stage-dot" style="background:#91d5ff;"></div>
<div class="stage-name">浅睡</div>
<div class="stage-bar-bg"><div class="stage-bar-fill" style="width:42%;background:#91d5ff;"></div></div>
<div class="stage-pct">42%</div>
<div class="stage-dur">3h 10min</div>
</div>
<!-- REM -->
<div class="stage-item">
<div class="stage-dot" style="background:#b37feb;"></div>
<div class="stage-name">REM</div>
<div class="stage-bar-bg"><div class="stage-bar-fill" style="width:20%;background:#b37feb;"></div></div>
<div class="stage-pct">20%</div>
<div class="stage-dur">1h 30min</div>
</div>
<!-- 清醒 -->
<div class="stage-item">
<div class="stage-dot" style="background:#ffd591;"></div>
<div class="stage-name">清醒</div>
<div class="stage-bar-bg"><div class="stage-bar-fill" style="width:10%;background:#ffd591;"></div></div>
<div class="stage-pct">10%</div>
<div class="stage-dur">46min</div>
</div>
</div>
</div>
<!-- 时间维度Tab -->
<div class="time-tabs">
<div class="time-tab active"></div>
<div class="time-tab"></div>
<div class="time-tab"></div>
</div>
<!-- 24h睡眠深度曲线 -->
<div class="section">
<div class="section-title">睡眠深度曲线</div>
<svg class="depth-chart" id="depthChart" viewBox="0 0 320 80" preserveAspectRatio="none"></svg>
<div class="depth-axis"><span>22:00</span><span>00:00</span><span>02:00</span><span>04:00</span><span>06:00</span><span>07:00</span></div>
</div>
<!-- 近7天趋势 -->
<div class="section">
<div class="section-title">近 7 天趋势</div>
<svg class="trend-chart" id="trendChart" viewBox="0 0 320 100" preserveAspectRatio="none"></svg>
<div class="trend-axis"><span>4/24</span><span>4/25</span><span>4/26</span><span>4/27</span><span>4/28</span><span>4/29</span><span>4/30</span></div>
</div>
<!-- 统计摘要 -->
<div class="summary">
<div class="summary-card">
<div class="summary-card__val">7h<br>20min</div>
<div class="summary-card__label">7日平均</div>
</div>
<div class="summary-card">
<div class="summary-card__val">8h<br>06min</div>
<div class="summary-card__label">最长睡眠</div>
</div>
<div class="summary-card">
<div class="summary-card__val">82</div>
<div class="summary-card__unit"></div>
<div class="summary-card__label">睡眠规律性</div>
</div>
</div>
<div class="bottom-safe"></div>
</div>
</div>
<script>
/* ---- 24h睡眠深度曲线(SVG路径,模拟真实睡眠波形) ---- */
(function() {
/* 睡眠深度数据:0=清醒, 1=浅睡, 2=深睡, 3=REM,共48个采样点(每15分钟一个) */
/* 时间范围:22:00 ~ 07:00,共9小时 = 36个15分钟 */
var depthData = [
0, 0, 1, 1, 2, 2, 2, 3, 3, 2, /* 22:00-24:30 入睡过渡 */
2, 2, 3, 3, 1, 1, 2, 2, 2, 3, /* 00:30-03:00 深睡阶段 */
3, 2, 1, 1, 0, 1, 2, 2, 3, 3, /* 03:00-05:30 REM循环 */
2, 1, 1, 0, 0, 0 /* 05:30-07:00 浅睡至清醒 */
];
var n = depthData.length;
var w = 320, h = 80;
var padT = 8, padB = 8;
var chartH = h - padT - padB;
/* 深度值映射:0=清醒(顶部), 3=深睡(底部) */
function yOf(d) { return padT + (d / 3) * chartH; }
/* 生成平滑路径点 */
var pts = depthData.map(function(d, i) {
return { x: (i / (n - 1)) * w, y: yOf(d) };
});
/* 构建平滑曲线(使用三次贝塞尔近似) */
var path = 'M ' + pts[0].x.toFixed(1) + ',' + pts[0].y.toFixed(1);
for (var i = 1; i < pts.length; i++) {
var prev = pts[i - 1], cur = pts[i];
var cpx = (prev.x + cur.x) / 2;
path += ' C ' + cpx.toFixed(1) + ',' + prev.y.toFixed(1) + ' ' + cpx.toFixed(1) + ',' + cur.y.toFixed(1) + ' ' + cur.x.toFixed(1) + ',' + cur.y.toFixed(1);
}
/* 填充区域路径 */
var areaPath = path + ' L ' + w + ',' + h + ' L 0,' + h + ' Z';
var svg = document.getElementById('depthChart');
svg.innerHTML =
'<defs>' +
'<linearGradient id="depthGrad" x1="0" y1="0" x2="0" y2="1">' +
'<stop offset="0%" stop-color="#1890ff" stop-opacity="0.35"/>' +
'<stop offset="100%" stop-color="#1890ff" stop-opacity="0.04"/>' +
'</linearGradient>' +
'</defs>' +
'<path d="' + areaPath + '" fill="url(#depthGrad)"/>' +
'<path d="' + path + '" fill="none" stroke="#1890ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>';
})();
/* ---- 近7天睡眠时长趋势折线图 ---- */
(function() {
/* 单位:小时 */
var weekData = [7.2, 6.8, 7.5, 8.1, 6.5, 7.8, 7.5];
var n = weekData.length;
var w = 320, h = 100;
var padT = 10, padB = 10;
var chartH = h - padT - padB;
var wMax = Math.max.apply(null, weekData);
var wMin = Math.min.apply(null, weekData);
var range = wMax - wMin || 1;
/* 计算各点坐标 */
var pts = weekData.map(function(v, i) {
return {
x: (i / (n - 1)) * w,
y: padT + (1 - (v - wMin) / range) * chartH
};
});
var line = pts.map(function(p) { return p.x.toFixed(1) + ',' + p.y.toFixed(1); }).join(' ');
var area = '0,' + h + ' ' + line + ' ' + w + ',' + h;
var svg = document.getElementById('trendChart');
svg.innerHTML =
'<defs>' +
'<linearGradient id="trendGrad" x1="0" y1="0" x2="0" y2="1">' +
'<stop offset="0%" stop-color="#1890ff" stop-opacity="0.3"/>' +
'<stop offset="100%" stop-color="#1890ff" stop-opacity="0.02"/>' +
'</linearGradient>' +
'</defs>' +
'<polygon points="' + area + '" fill="url(#trendGrad)"/>' +
'<polyline points="' + line + '" fill="none" stroke="#1890ff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>';
/* 数据点圆圈 */
pts.forEach(function(p) {
svg.innerHTML += '<circle cx="' + p.x.toFixed(1) + '" cy="' + p.y.toFixed(1) + '" r="4" fill="#fff" stroke="#1890ff" stroke-width="2"/>';
});
})();
/* ---- 时间维度Tab切换 ---- */
document.querySelectorAll('.time-tab').forEach(function(tab) {
tab.onclick = function() {
document.querySelectorAll('.time-tab').forEach(function(t) { t.classList.remove('active'); });
tab.classList.add('active');
};
});
</script>
</body>
</html>
+283
View File
@@ -0,0 +1,283 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>步数详情 - 健康监测 | 健康长庆原型</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #f0f2f5; display: flex; justify-content: center; align-items: flex-start; min-height: 100vh; padding: 20px; font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif; }
.phone-shell { width: 390px; height: 844px; background: #f5f7fa; border-radius: 44px; box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0; overflow: hidden; position: relative; display: flex; flex-direction: column; }
.status-bar { height: 44px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; flex-shrink: 0; background: #fff; }
.status-bar .time { font-size: 15px; font-weight: 700; color: #333; }
.status-bar .icons { display: flex; gap: 6px; align-items: center; }
.nav-bar { height: 44px; background: #fff; display: flex; align-items: center; padding: 0 16px; flex-shrink: 0; border-bottom: 1px solid #f0f0f0; }
.nav-back { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.nav-title { flex: 1; text-align: center; font-size: 17px; font-weight: 600; color: #1a1a1a; margin-right: 28px; }
.scroll-content { flex: 1; overflow-y: auto; background: #f5f7fa; }
.scroll-content::-webkit-scrollbar { display: none; }
.top-panel { background: #fff; padding-top: 4px; }
.time-tabs { display: flex; justify-content: center; gap: 42px; padding: 4px 0 14px; }
.time-tab { position: relative; font-size: 18px; color: #444; cursor: pointer; padding: 0 2px 8px; user-select: none; }
.time-tab.active { color: #68be5a; }
.time-tab.active::after { content: ''; position: absolute; left: 0; right: 0; bottom: 0; margin: auto; width: 22px; height: 3px; border-radius: 2px; background: #68be5a; }
.summary-area { text-align: center; padding: 18px 24px 8px; }
.date-row { display: inline-flex; align-items: center; gap: 6px; font-size: 18px; color: #666; font-weight: 500; }
.range-text { margin-top: 8px; font-size: 18px; color: #666; }
.steps-main { margin-top: 4px; display: inline-flex; align-items: baseline; gap: 4px; }
.steps-main__value { font-size: 58px; line-height: 1; font-weight: 500; color: #1f1f1f; letter-spacing: -1px; }
.steps-main__unit { font-size: 21px; color: #333; }
.chart-wrap { position: relative; background: #fff; padding: 10px 16px 0; }
.chart-box { position: relative; height: 258px; }
.chart-svg { width: 100%; height: 100%; display: block; }
.chart-grid line { stroke: #ececec; stroke-width: 1; }
.chart-path { fill: none; stroke: #74c26b; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.chart-dot { fill: #74c26b; }
.y-axis-labels { position: absolute; top: 6px; right: 0; width: 56px; height: 208px; pointer-events: none; }
.y-axis-label { position: absolute; right: 0; transform: translateY(50%); font-size: 12px; color: #666; }
.x-axis { display: grid; gap: 0; padding: 8px 14px 0 0; font-size: 12px; color: #666; }
.x-axis span { text-align: center; white-space: nowrap; }
.x-axis--dense { font-size: 10px; }
.timeline-knob-row { position: relative; margin: 2px 0 0; height: 30px; }
.timeline-track { position: absolute; left: 0; right: 0; top: 14px; height: 1px; background: #e8e8e8; }
.timeline-knob { position: absolute; right: 16px; top: 1px; width: 32px; height: 32px; border-radius: 50%; background: #fff; box-shadow: 0 2px 10px rgba(0,0,0,0.12); }
.card { margin: 12px 12px 0; background: #fff; border-radius: 22px; padding: 20px 18px 18px; }
.card-head { display: flex; align-items: center; justify-content: space-between; }
.card-title { font-size: 16px; font-weight: 600; color: #222; }
.edit-link { display: inline-flex; align-items: center; gap: 4px; font-size: 14px; color: #555; }
.goal-row { margin-top: 18px; display: flex; align-items: baseline; gap: 2px; color: #1f1f1f; }
.goal-row__current { font-size: 22px; font-weight: 600; }
.goal-row__divider, .goal-row__target, .goal-row__unit { font-size: 14px; color: #666; }
.goal-progress { margin-top: 18px; height: 26px; background: #f1f1f1; border-radius: 13px; position: relative; overflow: hidden; }
.goal-progress__fill { position: absolute; left: 0; top: 0; bottom: 0; width: 0; border-radius: 13px; background: linear-gradient(90deg, #58c45c 0%, #86df75 100%); }
.goal-progress__marker { position: absolute; left: 0; top: 50%; width: 18px; height: 18px; border-radius: 50%; transform: translate(-50%, -50%); background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,0.14); border: 1px solid #ededed; display: flex; align-items: center; justify-content: center; }
.goal-progress__marker::before { content: '🏃'; font-size: 9px; line-height: 1; filter: grayscale(1); opacity: 0.55; }
.motivation-row { margin-top: 22px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.motivation-text { font-size: 15px; color: #333; }
.walk-btn { flex-shrink: 0; min-width: 92px; height: 36px; border-radius: 18px; background: #f4f8ef; color: #7bc86b; display: flex; align-items: center; justify-content: center; font-size: 16px; font-weight: 500; }
.section-divider { height: 1px; background: #ececec; margin: 18px 0 14px; }
.metric-row { display: flex; align-items: flex-end; justify-content: space-between; padding: 10px 0; }
.metric-left__label { font-size: 14px; color: #777; margin-bottom: 6px; }
.metric-left__value { display: flex; align-items: baseline; gap: 4px; }
.metric-left__num { font-size: 18px; font-weight: 600; color: #222; }
.metric-left__unit { font-size: 14px; color: #777; }
.metric-right { font-size: 14px; color: #777; text-align: right; }
.metric-split { height: 1px; background: #ececec; }
.foot-note { padding: 14px 0 20px; text-align: center; font-size: 12px; color: #b3b3b3; }
.bottom-safe { height: 20px; flex-shrink: 0; }
</style>
</head>
<body>
<div class="phone-shell">
<div class="status-bar">
<span class="time">9:41</span>
<div class="icons">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<div class="nav-bar">
<div class="nav-back" onclick="location.href='index.html'">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2" stroke-linecap="round"><path d="M15 19l-7-7 7-7"/></svg>
</div>
<div class="nav-title">步数</div>
</div>
<div class="scroll-content">
<div class="top-panel">
<div class="time-tabs">
<div class="time-tab active" data-mode="day"></div>
<div class="time-tab" data-mode="week"></div>
<div class="time-tab" data-mode="month"></div>
<div class="time-tab" data-mode="year"></div>
</div>
<div class="summary-area">
<div class="date-row">
<span id="dateText">4月30日 周四</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="#666"><path d="M7 10l5 5 5-5z"/></svg>
</div>
<div class="range-text" id="rangeText">00:00-23:59</div>
<div class="steps-main">
<span class="steps-main__value" id="totalSteps">1,869</span>
<span class="steps-main__unit"></span>
</div>
</div>
<div class="chart-wrap">
<div class="chart-box">
<svg class="chart-svg" id="stepChart" viewBox="0 0 358 258" preserveAspectRatio="none">
<g class="chart-grid" id="chartGrid"></g>
<path id="stepLine" class="chart-path"></path>
<circle id="stepDot" class="chart-dot" r="3.2"></circle>
</svg>
<div class="y-axis-labels" id="yAxisLabels"></div>
</div>
<div class="x-axis" id="xAxis"></div>
<div class="timeline-knob-row">
<div class="timeline-track"></div>
<div class="timeline-knob"></div>
</div>
</div>
</div>
<div class="card">
<div class="metric-row">
<div class="metric-left">
<div class="metric-left__label">消耗</div>
<div class="metric-left__value">
<span class="metric-left__num" id="calorieValue">55</span>
<span class="metric-left__unit">千卡</span>
</div>
</div>
<div class="metric-right" id="calorieCompare">相当于消耗 1 个鸡蛋</div>
</div>
<div class="metric-split"></div>
<div class="metric-row">
<div class="metric-left">
<div class="metric-left__label">距离</div>
<div class="metric-left__value">
<span class="metric-left__num" id="distanceValue">1.33</span>
<span class="metric-left__unit">公里</span>
</div>
</div>
<div class="metric-right" id="distanceCompare">相当于走了 3.34 圈标准操场</div>
</div>
</div>
<div class="foot-note">以上数据由步数换算而成</div>
<div class="bottom-safe"></div>
</div>
</div>
<script>
var dataMap = {
day: {
dateText: '4月30日 周四',
rangeText: '00:00-23:59',
goal: 10000,
points: [0,0,0,0,0,0,0,30,60,80,90,110,130,180,240,320,420,560,720,960,1210,1480,1680,1869],
totalMode: 'last',
yTicks: [12000, 9000, 6000, 3000, 0],
xLabels: ['00:00','06:00','12:00','18:00','24:00']
},
week: {
dateText: '4月27日-5月3日',
rangeText: '周一-周日',
goal: 70000,
points: [8320, 9640, 10280, 1869, 7560, 5410, 5660],
totalMode: 'sum',
yTicks: [12000, 9000, 6000, 3000, 0],
xLabels: ['周一','周二','周三','周四','周五','周六','周日']
},
month: {
dateText: '2026年4月',
rangeText: '4月1日-4月30日',
goal: 300000,
points: [6820,7540,8930,10220,11840,9650,8420,7340,9680,10420,8890,9120,10330,11540,10980,8450,7920,9380,10110,12040,11420,9860,8750,9640,10820,11360,9240,10150,8350,1869],
totalMode: 'sum',
yTicks: [12000, 9000, 6000, 3000, 0],
xLabels: ['1日','7日','14日','21日','30日']
},
year: {
dateText: '2026年',
rangeText: '1月-12月',
goal: 3650000,
points: [86210, 91420, 95860, 278509, 102340, 98760, 104220, 109680, 113450, 118020, 110340, 106580],
totalMode: 'sum',
yTicks: [300000, 225000, 150000, 75000, 0],
xLabels: ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月']
}
};
function formatNumber(num) {
return Number(num).toLocaleString('zh-CN');
}
function getTotal(cfg) {
if (cfg.totalMode === 'last') return cfg.points[cfg.points.length - 1];
return cfg.points.reduce(function(sum, item) { return sum + item; }, 0);
}
function getDistance(total) {
return (total * 0.0007116).toFixed(2);
}
function getCalories(total) {
return Math.round(total * 0.02943);
}
function renderChart(cfg) {
var chartWidth = 330;
var chartHeight = 208;
var startY = 22;
var stepX = chartWidth / (cfg.points.length - 1 || 1);
var maxY = cfg.yTicks[0];
var grid = document.getElementById('chartGrid');
var yAxis = document.getElementById('yAxisLabels');
var xAxis = document.getElementById('xAxis');
var lineEl = document.getElementById('stepLine');
var dotEl = document.getElementById('stepDot');
grid.innerHTML = '';
cfg.yTicks.forEach(function(item, index) {
var y = startY + (chartHeight / (cfg.yTicks.length - 1)) * index;
grid.innerHTML += '<line x1="0" y1="' + y.toFixed(1) + '" x2="330" y2="' + y.toFixed(1) + '"></line>';
});
yAxis.innerHTML = cfg.yTicks.map(function(item, index) {
return '<span class="y-axis-label" style="top:' + (index * 52) + 'px">' + formatNumber(item) + '</span>';
}).join('');
var points = cfg.points.map(function(v, i) {
var x = i * stepX;
var y = startY + chartHeight - (v / maxY) * chartHeight;
return { x: x, y: y };
});
var d = points.map(function(point, index) {
return (index === 0 ? 'M' : 'L') + point.x.toFixed(1) + ',' + point.y.toFixed(1);
}).join(' ');
lineEl.setAttribute('d', d);
var last = points[points.length - 1];
dotEl.setAttribute('cx', last.x.toFixed(1));
dotEl.setAttribute('cy', last.y.toFixed(1));
xAxis.className = 'x-axis' + (cfg.xLabels.length > 7 ? ' x-axis--dense' : '');
xAxis.style.gridTemplateColumns = 'repeat(' + cfg.xLabels.length + ', 1fr)';
xAxis.innerHTML = cfg.xLabels.map(function(label) {
return '<span>' + label + '</span>';
}).join('');
}
function renderMode(mode) {
var cfg = dataMap[mode];
var total = getTotal(cfg);
var distance = getDistance(total);
var calories = getCalories(total);
var eggs = Math.max(1, Math.round(calories / 55));
var laps = (Number(distance) / 0.4).toFixed(2);
document.getElementById('dateText').textContent = cfg.dateText;
document.getElementById('rangeText').textContent = cfg.rangeText;
document.getElementById('totalSteps').textContent = formatNumber(total);
document.getElementById('calorieValue').textContent = formatNumber(calories);
document.getElementById('distanceValue').textContent = distance;
document.getElementById('calorieCompare').textContent = '相当于消耗 ' + eggs + ' 个鸡蛋';
document.getElementById('distanceCompare').textContent = '相当于走了 ' + laps + ' 圈标准操场';
renderChart(cfg);
}
document.querySelectorAll('.time-tab').forEach(function(tab) {
tab.onclick = function() {
document.querySelectorAll('.time-tab').forEach(function(item) { item.classList.remove('active'); });
tab.classList.add('active');
renderMode(tab.getAttribute('data-mode'));
};
});
renderMode('day');
</script>
</body>
</html>
@@ -0,0 +1,362 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>压力预警历史 - 健康监测 | 健康长庆原型</title>
<style>
/* 全局重置 */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #f0f2f5;
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
}
/* 手机外壳 */
.phone-shell {
width: 390px;
height: 844px;
background: #f5f7fa;
border-radius: 44px;
box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
}
/* 状态栏 */
.status-bar {
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
flex-shrink: 0;
background: #fff;
}
.status-bar .time { font-size: 15px; font-weight: 700; color: #333; }
.status-bar .icons { display: flex; gap: 6px; align-items: center; }
/* 导航栏 */
.nav-bar {
height: 44px;
background: #fff;
display: flex;
align-items: center;
padding: 0 16px;
flex-shrink: 0;
border-bottom: 1px solid #f0f0f0;
}
.nav-back {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.nav-title {
flex: 1;
text-align: center;
font-size: 17px;
font-weight: 600;
color: #1a1a1a;
margin-right: 28px;
}
/* 滚动内容区 */
.scroll-content {
flex: 1;
overflow-y: auto;
background: #f5f7fa;
}
.scroll-content::-webkit-scrollbar { display: none; }
/* 预警统计卡片 */
.stats-card {
margin: 12px 12px 0;
background: #fff;
border-radius: 14px;
padding: 20px 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
.stats-card__left { display: flex; flex-direction: column; align-items: flex-start; }
.stats-card__label { font-size: 13px; color: #999; margin-bottom: 6px; }
.stats-card__count-row { display: flex; align-items: baseline; gap: 4px; }
/* 压力预警统计数字使用橙色 */
.stats-card__count { font-size: 40px; font-weight: 800; color: #fa8c16; line-height: 1; }
.stats-card__unit { font-size: 14px; color: #fa8c16; font-weight: 600; }
.stats-card__right { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
.stats-card__recent-label { font-size: 12px; color: #999; }
.stats-card__recent-time { font-size: 13px; font-weight: 600; color: #333; }
/* 预警图标装饰(橙色背景圆形) */
.stats-card__icon {
width: 48px;
height: 48px;
background: #fff7e6;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
}
/* 列表区块标题 */
.section-title {
font-size: 13px;
color: #999;
padding: 14px 16px 8px;
font-weight: 500;
}
/* 预警记录卡片 */
.alert-card {
margin: 0 12px 10px;
background: #fff;
border-radius: 14px;
padding: 14px 14px 14px 12px;
display: flex;
gap: 12px;
align-items: flex-start;
}
/* 左侧等级圆点列 */
.alert-card__dot-col {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 3px;
gap: 4px;
}
.alert-card__dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
/* 严重:红色圆点 */
.alert-card__dot--critical { background: #ff4d4f; box-shadow: 0 0 0 3px rgba(255,77,79,0.18); }
/* 警告:黄色圆点 */
.alert-card__dot--warning { background: #faad14; box-shadow: 0 0 0 3px rgba(250,173,20,0.18); }
/* 卡片主体内容 */
.alert-card__body { flex: 1; min-width: 0; }
.alert-card__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.alert-card__time { font-size: 13px; color: #666; }
.alert-card__badge {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
flex-shrink: 0;
}
/* 严重标签:红色 */
.alert-card__badge--critical { background: #fff1f0; color: #ff4d4f; }
/* 警告标签:黄色 */
.alert-card__badge--warning { background: #fffbe6; color: #d48806; }
/* 监测值行 */
.alert-card__value-row {
display: flex;
align-items: baseline;
gap: 4px;
margin-bottom: 6px;
}
.alert-card__value { font-size: 24px; font-weight: 800; color: #1a1a1a; line-height: 1; }
.alert-card__value-unit { font-size: 13px; color: #999; }
/* 详情行(持续时长等) */
.alert-card__detail-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.alert-card__detail-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #999;
}
.alert-card__detail-item svg { flex-shrink: 0; }
/* 触发原因说明框 */
.alert-card__reason {
margin-top: 8px;
font-size: 12px;
color: #bbb;
background: #fafafa;
border-radius: 6px;
padding: 6px 8px;
line-height: 1.5;
}
/* 底部安全区 */
.bottom-safe { height: 20px; flex-shrink: 0; }
</style>
</head>
<body>
<div class="phone-shell">
<!-- 状态栏 -->
<div class="status-bar">
<span class="time">9:41</span>
<div class="icons">
<!-- WiFi 图标 -->
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2">
<path d="M5 12.55a11 11 0 0 1 14.08 0"/>
<path d="M1.42 9a16 16 0 0 1 21.16 0"/>
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"/>
<circle cx="12" cy="20" r="1" fill="#333" stroke="none"/>
</svg>
<!-- 电池图标 -->
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2">
<rect x="1" y="6" width="18" height="12" rx="2"/>
<path d="M21 10v4"/>
<rect x="3" y="8" width="12" height="8" rx="1" fill="#333" stroke="none"/>
</svg>
</div>
</div>
<!-- 导航栏:返回压力详情页 -->
<div class="nav-bar">
<div class="nav-back" onclick="location.href='stress.html'">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2" stroke-linecap="round">
<path d="M15 19l-7-7 7-7"/>
</svg>
</div>
<div class="nav-title">压力预警历史</div>
</div>
<!-- 滚动内容区 -->
<div class="scroll-content">
<!-- 预警统计卡片 -->
<div class="stats-card">
<div class="stats-card__left">
<div class="stats-card__label">本月预警</div>
<!-- 本月预警次数:橙色大字 -->
<div class="stats-card__count-row">
<span class="stats-card__count">2</span>
<span class="stats-card__unit"></span>
</div>
</div>
<div class="stats-card__right">
<!-- 压力预警图标:橙色脑波/压力符号 -->
<div class="stats-card__icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<!-- 简化压力波形图标 -->
<path d="M3 12 L6 7 L9 15 L12 9 L15 14 L18 10 L21 12"
stroke="#fa8c16" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="stats-card__recent-label">最近预警时间</div>
<div class="stats-card__recent-time">2026-04-30 16:30</div>
</div>
</div>
<!-- 预警记录列表标题 -->
<div class="section-title">预警记录(共 2 条)</div>
<!-- 预警记录 1:警告 - 2026-04-30 16:30,压力值 82 -->
<div class="alert-card">
<!-- 左侧警告等级黄色圆点 -->
<div class="alert-card__dot-col">
<div class="alert-card__dot alert-card__dot--warning"></div>
</div>
<div class="alert-card__body">
<!-- 时间 + 警告等级标签 -->
<div class="alert-card__header">
<span class="alert-card__time">2026-04-30 16:30</span>
<span class="alert-card__badge alert-card__badge--warning">警告</span>
</div>
<!-- 压力监测值 -->
<div class="alert-card__value-row">
<span class="alert-card__value">82</span>
<span class="alert-card__value-unit">压力值</span>
</div>
<!-- 持续时长 + 压力标签 -->
<div class="alert-card__detail-row">
<!-- 时钟图标 + 持续时长 -->
<div class="alert-card__detail-item">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#bbb" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
<span>持续 25 分钟</span>
</div>
<!-- 压力类型标签 -->
<div class="alert-card__detail-item">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#bbb" stroke-width="2">
<path d="M3 12 L6 8 L9 14 L12 9 L15 13 L18 10 L21 12"/>
</svg>
<span>压力</span>
</div>
</div>
<!-- 触发原因 -->
<div class="alert-card__reason">触发原因:压力值超过 80 阈值</div>
</div>
</div>
<!-- 预警记录 2:严重 - 2026-04-22 15:10,压力值 88 -->
<div class="alert-card">
<!-- 左侧严重等级红色圆点 -->
<div class="alert-card__dot-col">
<div class="alert-card__dot alert-card__dot--critical"></div>
</div>
<div class="alert-card__body">
<!-- 时间 + 严重等级标签 -->
<div class="alert-card__header">
<span class="alert-card__time">2026-04-22 15:10</span>
<span class="alert-card__badge alert-card__badge--critical">严重</span>
</div>
<!-- 压力监测值 -->
<div class="alert-card__value-row">
<span class="alert-card__value">88</span>
<span class="alert-card__value-unit">压力值</span>
</div>
<!-- 持续时长 + 压力标签 -->
<div class="alert-card__detail-row">
<!-- 时钟图标 + 持续时长 -->
<div class="alert-card__detail-item">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#bbb" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
<span>持续 40 分钟</span>
</div>
<!-- 压力类型标签 -->
<div class="alert-card__detail-item">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#bbb" stroke-width="2">
<path d="M3 12 L6 8 L9 14 L12 9 L15 13 L18 10 L21 12"/>
</svg>
<span>压力</span>
</div>
</div>
<!-- 触发原因 -->
<div class="alert-card__reason">触发原因:压力值超过 85 阈值</div>
</div>
</div>
<!-- 底部安全区 -->
<div class="bottom-safe"></div>
</div><!-- /scroll-content -->
</div><!-- /phone-shell -->
</body>
</html>
+315
View File
@@ -0,0 +1,315 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>压力详情 - 健康监测 | 健康长庆原型</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #f0f2f5; display: flex; justify-content: center; align-items: flex-start; min-height: 100vh; padding: 20px; font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif; }
.phone-shell { width: 390px; height: 844px; background: #f5f7fa; border-radius: 44px; box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0; overflow: hidden; position: relative; display: flex; flex-direction: column; }
.status-bar { height: 44px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; flex-shrink: 0; background: #fff; }
.status-bar .time { font-size: 15px; font-weight: 700; color: #333; }
.status-bar .icons { display: flex; gap: 6px; align-items: center; }
.nav-bar { height: 44px; background: #fff; display: flex; align-items: center; padding: 0 16px; flex-shrink: 0; border-bottom: 1px solid #f0f0f0; }
.nav-back { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.nav-title { flex: 1; text-align: center; font-size: 17px; font-weight: 600; color: #1a1a1a; margin-right: 28px; }
.scroll-content { flex: 1; overflow-y: auto; background: #f5f7fa; }
.scroll-content::-webkit-scrollbar { display: none; }
/* 主题色(压力橙) */
:root { --theme: #faad14; --theme2: #ffc53d; }
.top-panel { background: #fff; padding-top: 2px; }
.time-tabs { display: flex; justify-content: center; gap: 42px; padding: 2px 0 8px; }
.time-tab { position: relative; font-size: 18px; color: #444; cursor: pointer; padding: 0 2px 6px; user-select: none; }
.time-tab.active { color: var(--theme); }
.time-tab.active::after { content: ''; position: absolute; left: 0; right: 0; bottom: 0; margin: auto; width: 22px; height: 3px; border-radius: 2px; background: var(--theme); }
.summary-area { text-align: center; padding: 12px 24px 6px; }
.date-row { display: inline-flex; align-items: center; gap: 6px; font-size: 16px; color: #666; font-weight: 500; }
.range-text { margin-top: 4px; font-size: 16px; color: #666; }
.main-row { margin-top: 2px; display: inline-flex; align-items: baseline; gap: 4px; }
.main-row__value { font-size: 48px; line-height: 1; font-weight: 500; color: #1f1f1f; letter-spacing: -1px; }
.main-row__unit { font-size: 18px; color: #333; }
.main-badge { display: inline-flex; align-items: center; justify-content: center; margin-top: 6px; padding: 3px 12px; border-radius: 18px; font-size: 12px; font-weight: 600; background: #fffbe6; color: #d48806; }
.level-tip { margin-top: 12px; padding: 12px 0; font-size: 12px; line-height: 1.6; color: #666; }
.chart-wrap { position: relative; background: #fff; padding: 6px 16px 0; }
.chart-box { position: relative; height: 160px; }
.chart-svg { width: 100%; height: 100%; display: block; }
.chart-grid line { stroke: #ececec; stroke-width: 1; }
.chart-path { fill: none; stroke: var(--theme); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.chart-dot { fill: var(--theme); }
.y-axis-labels { position: absolute; top: 6px; right: 0; width: 56px; height: 208px; pointer-events: none; }
.y-axis-label { position: absolute; right: 0; transform: translateY(50%); font-size: 12px; color: #666; }
.x-axis { display: grid; gap: 0; padding: 8px 14px 0 0; font-size: 12px; color: #666; }
.x-axis span { text-align: center; white-space: nowrap; }
.x-axis--dense { font-size: 10px; }
.timeline-knob-row { position: relative; margin: 0; height: 24px; }
.timeline-track { position: absolute; left: 0; right: 0; top: 10px; height: 1px; background: #e8e8e8; }
.timeline-knob { position: absolute; right: 16px; top: -4px; width: 24px; height: 24px; border-radius: 50%; background: #fff; box-shadow: 0 2px 10px rgba(0,0,0,0.12); }
.card { margin: 8px 12px 0; background: #fff; border-radius: 22px; padding: 14px 12px; display: flex; gap: 0; }
.metric-row { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 6px 6px; }
.metric-left__label { font-size: 12px; color: #777; margin-bottom: 4px; }
.metric-left__value { display: flex; align-items: baseline; gap: 2px; }
.metric-left__num { font-size: 16px; font-weight: 600; color: #222; }
.metric-left__unit { font-size: 12px; color: #777; }
.metric-right { font-size: 12px; color: #777; text-align: center; }
.metric-split { width: 1px; background: #ececec; margin: 0; }
.alert-btn { margin: 8px 12px 0; border: 1.5px solid var(--theme); border-radius: 12px; padding: 11px; text-align: center; font-size: 13px; color: var(--theme); font-weight: 600; cursor: pointer; background: #fff; display: flex; align-items: center; justify-content: center; gap: 6px; }
.foot-note { padding: 10px 0 12px; text-align: center; font-size: 11px; color: #b3b3b3; }
.bottom-safe { height: 12px; flex-shrink: 0; }
</style>
</head>
<body>
<div class="phone-shell">
<div class="status-bar">
<span class="time">9:41</span>
<div class="icons">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<div class="nav-bar">
<div class="nav-back" onclick="location.href='index.html'">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2" stroke-linecap="round"><path d="M15 19l-7-7 7-7"/></svg>
</div>
<div class="nav-title">压力</div>
</div>
<div class="scroll-content">
<div class="top-panel">
<div class="time-tabs">
<div class="time-tab active" data-mode="day"></div>
<div class="time-tab" data-mode="week"></div>
<div class="time-tab" data-mode="month"></div>
<div class="time-tab" data-mode="year"></div>
</div>
<div class="summary-area">
<div class="date-row">
<span id="dateText">4月30日 周四</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="#666"><path d="M7 10l5 5 5-5z"/></svg>
</div>
<div class="range-text" id="rangeText">00:00-23:59</div>
<div class="main-row">
<span class="main-row__value" id="mainValue">42</span>
<span class="main-row__unit" id="mainUnit"></span>
</div>
<div class="main-badge" id="statusBadge">放松</div>
<div class="level-tip">压力等级说明:1-29放松 / 30-59正常 / 60-79中等 / 80-100偏高</div>
</div>
<div class="chart-wrap">
<div class="chart-box">
<svg class="chart-svg" id="chart" viewBox="0 0 358 258" preserveAspectRatio="none">
<g class="chart-grid" id="chartGrid"></g>
<path id="line" class="chart-path"></path>
<circle id="dot" class="chart-dot" r="3.2"></circle>
</svg>
<div class="y-axis-labels" id="yAxisLabels"></div>
</div>
<div class="x-axis" id="xAxis"></div>
<div class="timeline-knob-row">
<div class="timeline-track"></div>
<div class="timeline-knob"></div>
</div>
</div>
</div>
<div class="card">
<div class="metric-row">
<div class="metric-left">
<div class="metric-left__label">最高</div>
<div class="metric-left__value"><span class="metric-left__num" id="maxValue">82</span><span class="metric-left__unit"></span></div>
</div>
<div class="metric-right" id="maxHint">16:30</div>
</div>
<div class="metric-split"></div>
<div class="metric-row">
<div class="metric-left">
<div class="metric-left__label">最低</div>
<div class="metric-left__value"><span class="metric-left__num" id="minValue">18</span><span class="metric-left__unit"></span></div>
</div>
<div class="metric-right" id="minHint">05:10</div>
</div>
<div class="metric-split"></div>
<div class="metric-row">
<div class="metric-left">
<div class="metric-left__label">平均</div>
<div class="metric-left__value"><span class="metric-left__num" id="avgValue">46</span><span class="metric-left__unit"></span></div>
</div>
<div class="metric-right" id="avgHint">全天</div>
</div>
</div>
<div class="alert-btn" onclick="location.href='stress-alerts.html'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--theme)" stroke-width="2"><path d="M12 9v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
查看预警历史
</div>
<div class="foot-note">以上数据为原型示例,实际以设备同步为准</div>
<div class="bottom-safe"></div>
</div>
</div>
<script>
var dataMap = {
day: {
dateText: '4月30日 周四',
rangeText: '00:00-23:59',
unit: '分',
decimals: 0,
points: [35,32,30,28,24,18,22,26,34,40,52,60,68,74,82,78,66,58,49,45,41,38,36,34],
totalMode: 'last',
yTicks: [100, 80, 60, 40, 20, 0],
xLabels: ['0:00','6:00','12:00','18:00','24:00'],
status: '放松',
max: { v:82, hint:'16:30' },
min: { v:18, hint:'05:10' },
avg: { v:46, hint:'全天' }
},
week: {
dateText: '4月27日-5月3日',
rangeText: '周一-周日',
unit: '分',
decimals: 0,
points: [45,52,38,61,42,55,42],
totalMode: 'avg',
yTicks: [100, 80, 60, 40, 20, 0],
xLabels: ['周一','周二','周三','周四','周五','周六','周日'],
status: '正常',
max: { v:61, hint:'周四' },
min: { v:38, hint:'周三' },
avg: { v:48, hint:'本周' }
},
month: {
dateText: '2026年4月',
rangeText: '4月1日-4月30日',
unit: '分',
decimals: 0,
points: [42,45,38,48,41,52,39,44,47,50,48,45,41,38,51,53,49,46,43,40,38,42,45,48,51,48,45,42,39,46],
totalMode: 'avg',
yTicks: [100, 80, 60, 40, 20, 0],
xLabels: ['1日','7日','14日','21日','30日'],
status: '正常',
max: { v:53, hint:'04-16' },
min: { v:38, hint:'04-08' },
avg: { v:45, hint:'本月' }
},
year: {
dateText: '2026年',
rangeText: '1月-12月',
unit: '分',
decimals: 0,
points: [48,45,42,46,48,45,42,45,48,51,49,46],
totalMode: 'avg',
yTicks: [100, 80, 60, 40, 20, 0],
xLabels: ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'],
status: '正常',
max: { v:51, hint:'10月' },
min: { v:42, hint:'03月' },
avg: { v:46, hint:'全年' }
}
};
function avg(arr) {
if (!arr.length) return 0;
var sum = arr.reduce(function(s, v){ return s + v; }, 0);
return sum / arr.length;
}
function getValue(cfg) {
if (cfg.totalMode === 'last') return cfg.points[cfg.points.length - 1];
if (cfg.totalMode === 'avg') return avg(cfg.points);
if (cfg.totalMode === 'max') return Math.max.apply(null, cfg.points);
if (cfg.totalMode === 'min') return Math.min.apply(null, cfg.points);
return cfg.points[cfg.points.length - 1];
}
function formatValue(val, decimals) {
if (typeof val !== 'number') return String(val);
if (decimals != null) return val.toFixed(decimals);
return String(val);
}
function renderChart(cfg) {
var chartWidth = 330;
var chartHeight = 208;
var startY = 22;
var stepX = chartWidth / (cfg.points.length - 1 || 1);
var maxY = cfg.yTicks[0];
var grid = document.getElementById('chartGrid');
var yAxis = document.getElementById('yAxisLabels');
var xAxis = document.getElementById('xAxis');
var lineEl = document.getElementById('line');
var dotEl = document.getElementById('dot');
grid.innerHTML = '';
cfg.yTicks.forEach(function(_, index) {
var y = startY + (chartHeight / (cfg.yTicks.length - 1)) * index;
grid.innerHTML += '<line x1="0" y1="' + y.toFixed(1) + '" x2="330" y2="' + y.toFixed(1) + '"></line>';
});
yAxis.innerHTML = cfg.yTicks.map(function(tick, index) {
return '<span class="y-axis-label" style="top:' + (index * 52) + 'px">' + formatValue(tick, 0) + '</span>';
}).join('');
var pts = cfg.points.map(function(v, i) {
var x = i * stepX;
var y = startY + chartHeight - (v / maxY) * chartHeight;
return { x: x, y: y };
});
var d = pts.map(function(p, index) {
return (index === 0 ? 'M' : 'L') + p.x.toFixed(1) + ',' + p.y.toFixed(1);
}).join(' ');
lineEl.setAttribute('d', d);
var last = pts[pts.length - 1];
dotEl.setAttribute('cx', last.x.toFixed(1));
dotEl.setAttribute('cy', last.y.toFixed(1));
xAxis.className = 'x-axis' + (cfg.xLabels.length > 7 ? ' x-axis--dense' : '');
xAxis.style.gridTemplateColumns = 'repeat(' + cfg.xLabels.length + ', 1fr)';
xAxis.innerHTML = cfg.xLabels.map(function(label) {
return '<span>' + label + '</span>';
}).join('');
}
function renderMode(mode) {
var cfg = dataMap[mode];
var value = getValue(cfg);
document.getElementById('dateText').textContent = cfg.dateText;
document.getElementById('rangeText').textContent = cfg.rangeText;
document.getElementById('mainValue').textContent = formatValue(value, cfg.decimals);
document.getElementById('mainUnit').textContent = cfg.unit;
document.getElementById('statusBadge').textContent = cfg.status;
document.getElementById('maxValue').textContent = formatValue(cfg.max.v, cfg.decimals);
document.getElementById('maxHint').textContent = cfg.max.hint;
document.getElementById('minValue').textContent = formatValue(cfg.min.v, cfg.decimals);
document.getElementById('minHint').textContent = cfg.min.hint;
document.getElementById('avgValue').textContent = formatValue(cfg.avg.v, cfg.decimals);
document.getElementById('avgHint').textContent = cfg.avg.hint;
renderChart(cfg);
}
document.querySelectorAll('.time-tab').forEach(function(tab) {
tab.onclick = function() {
document.querySelectorAll('.time-tab').forEach(function(item) { item.classList.remove('active'); });
tab.classList.add('active');
renderMode(tab.getAttribute('data-mode'));
};
});
renderMode('day');
</script>
</body>
</html>
@@ -0,0 +1,314 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>体温预警历史 - 健康监测 | 健康长庆原型</title>
<style>
/* 全局重置 */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #f0f2f5;
display: flex;
justify-content: center;
align-items: flex-start;
min-height: 100vh;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
}
/* 手机外壳 */
.phone-shell {
width: 390px;
height: 844px;
background: #f5f7fa;
border-radius: 44px;
box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
}
/* 状态栏 */
.status-bar {
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
flex-shrink: 0;
background: #fff;
}
.status-bar .time { font-size: 15px; font-weight: 700; color: #333; }
.status-bar .icons { display: flex; gap: 6px; align-items: center; }
/* 导航栏 */
.nav-bar {
height: 44px;
background: #fff;
display: flex;
align-items: center;
padding: 0 16px;
flex-shrink: 0;
border-bottom: 1px solid #f0f0f0;
}
.nav-back {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.nav-title {
flex: 1;
text-align: center;
font-size: 17px;
font-weight: 600;
color: #1a1a1a;
margin-right: 28px;
}
/* 滚动内容区 */
.scroll-content {
flex: 1;
overflow-y: auto;
background: #f5f7fa;
}
.scroll-content::-webkit-scrollbar { display: none; }
/* 预警统计卡片 */
.stats-card {
margin: 12px 12px 0;
background: #fff;
border-radius: 14px;
padding: 20px 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
.stats-card__left { display: flex; flex-direction: column; align-items: flex-start; }
.stats-card__label { font-size: 13px; color: #999; margin-bottom: 6px; }
.stats-card__count-row { display: flex; align-items: baseline; gap: 4px; }
.stats-card__count { font-size: 40px; font-weight: 800; color: #faad14; line-height: 1; }
.stats-card__unit { font-size: 14px; color: #faad14; font-weight: 600; }
.stats-card__right { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
.stats-card__recent-label { font-size: 12px; color: #999; }
.stats-card__recent-time { font-size: 13px; font-weight: 600; color: #333; }
/* 预警图标装饰 */
.stats-card__icon {
width: 48px;
height: 48px;
background: #fffbe6;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
}
/* 列表区块标题 */
.section-title {
font-size: 13px;
color: #999;
padding: 14px 16px 8px;
font-weight: 500;
}
/* 预警记录卡片 */
.alert-card {
margin: 0 12px 10px;
background: #fff;
border-radius: 14px;
padding: 14px 14px 14px 12px;
display: flex;
gap: 12px;
align-items: flex-start;
}
/* 左侧等级圆点 */
.alert-card__dot-col {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 3px;
gap: 4px;
}
.alert-card__dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.alert-card__dot--critical { background: #ff4d4f; box-shadow: 0 0 0 3px rgba(255,77,79,0.18); }
.alert-card__dot--warning { background: #faad14; box-shadow: 0 0 0 3px rgba(250,173,20,0.18); }
/* 卡片主体内容 */
.alert-card__body { flex: 1; min-width: 0; }
.alert-card__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.alert-card__time { font-size: 13px; color: #666; }
.alert-card__badge {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
flex-shrink: 0;
}
.alert-card__badge--critical { background: #fff1f0; color: #ff4d4f; }
.alert-card__badge--warning { background: #fffbe6; color: #d48806; }
/* 监测值 */
.alert-card__value-row {
display: flex;
align-items: baseline;
gap: 4px;
margin-bottom: 6px;
}
.alert-card__value { font-size: 24px; font-weight: 800; color: #1a1a1a; line-height: 1; }
.alert-card__value-unit { font-size: 13px; color: #999; }
/* 详情行 */
.alert-card__detail-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.alert-card__detail-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #999;
}
.alert-card__detail-item svg { flex-shrink: 0; }
/* 触发原因 */
.alert-card__reason {
margin-top: 8px;
font-size: 12px;
color: #bbb;
background: #fafafa;
border-radius: 6px;
padding: 6px 8px;
line-height: 1.5;
}
/* 底部安全区 */
.bottom-safe { height: 20px; flex-shrink: 0; }
</style>
</head>
<body>
<div class="phone-shell">
<!-- 状态栏 -->
<div class="status-bar">
<span class="time">9:41</span>
<div class="icons">
<!-- WiFi 图标 -->
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2">
<path d="M5 12.55a11 11 0 0 1 14.08 0"/>
<path d="M1.42 9a16 16 0 0 1 21.16 0"/>
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"/>
<circle cx="12" cy="20" r="1" fill="#333" stroke="none"/>
</svg>
<!-- 电池图标 -->
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2">
<rect x="1" y="6" width="18" height="12" rx="2"/>
<path d="M21 10v4"/>
<rect x="3" y="8" width="12" height="8" rx="1" fill="#333" stroke="none"/>
</svg>
</div>
</div>
<!-- 导航栏:返回体温详情页 -->
<div class="nav-bar">
<div class="nav-back" onclick="location.href='temperature.html'">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2" stroke-linecap="round">
<path d="M15 19l-7-7 7-7"/>
</svg>
</div>
<div class="nav-title">体温预警历史</div>
</div>
<!-- 滚动内容区 -->
<div class="scroll-content">
<!-- 预警统计卡片 -->
<div class="stats-card">
<div class="stats-card__left">
<div class="stats-card__label">本月预警</div>
<div class="stats-card__count-row">
<span class="stats-card__count">1</span>
<span class="stats-card__unit"></span>
</div>
</div>
<div class="stats-card__right">
<!-- 体温预警图标 -->
<div class="stats-card__icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z" stroke="#faad14" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="stats-card__recent-label">最近预警时间</div>
<div class="stats-card__recent-time">2026-04-15 13:28</div>
</div>
</div>
<!-- 预警记录列表标题 -->
<div class="section-title">预警记录(共 1 条)</div>
<!-- 预警记录:警告 - 2026-04-15 -->
<div class="alert-card">
<!-- 左侧警告等级黄点 -->
<div class="alert-card__dot-col">
<div class="alert-card__dot alert-card__dot--warning"></div>
</div>
<div class="alert-card__body">
<!-- 时间 + 警告等级标签 -->
<div class="alert-card__header">
<span class="alert-card__time">2026-04-15 13:28</span>
<span class="alert-card__badge alert-card__badge--warning">警告</span>
</div>
<!-- 体温监测值 -->
<div class="alert-card__value-row">
<span class="alert-card__value">37.8</span>
<span class="alert-card__value-unit">°C</span>
</div>
<!-- 持续时长 + 体温标签 -->
<div class="alert-card__detail-row">
<!-- 时钟图标 + 持续时长 -->
<div class="alert-card__detail-item">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#bbb" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
<span>持续 45 分钟</span>
</div>
<!-- 体温标签 -->
<div class="alert-card__detail-item">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#bbb" stroke-width="2">
<path d="M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z"/>
</svg>
<span>体温</span>
</div>
</div>
<!-- 触发原因 -->
<div class="alert-card__reason">触发原因:体温超过 37.3°C 阈值</div>
</div>
</div>
<!-- 底部安全区 -->
<div class="bottom-safe"></div>
</div><!-- /scroll-content -->
</div><!-- /phone-shell -->
</body>
</html>
@@ -0,0 +1,312 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>体温详情 - 健康监测 | 健康长庆原型</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #f0f2f5; display: flex; justify-content: center; align-items: flex-start; min-height: 100vh; padding: 20px; font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif; }
.phone-shell { width: 390px; height: 844px; background: #f5f7fa; border-radius: 44px; box-shadow: 0 20px 60px rgba(0,0,0,0.25), inset 0 0 0 2px #e0e0e0; overflow: hidden; position: relative; display: flex; flex-direction: column; }
.status-bar { height: 44px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; flex-shrink: 0; background: #fff; }
.status-bar .time { font-size: 15px; font-weight: 700; color: #333; }
.status-bar .icons { display: flex; gap: 6px; align-items: center; }
.nav-bar { height: 44px; background: #fff; display: flex; align-items: center; padding: 0 16px; flex-shrink: 0; border-bottom: 1px solid #f0f0f0; }
.nav-back { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.nav-title { flex: 1; text-align: center; font-size: 17px; font-weight: 600; color: #1a1a1a; margin-right: 28px; }
.scroll-content { flex: 1; overflow-y: auto; background: #f5f7fa; }
.scroll-content::-webkit-scrollbar { display: none; }
/* 主题色(体温紫) */
:root { --theme: #722ed1; --theme2: #b37feb; }
.top-panel { background: #fff; padding-top: 2px; }
.time-tabs { display: flex; justify-content: center; gap: 42px; padding: 2px 0 8px; }
.time-tab { position: relative; font-size: 18px; color: #444; cursor: pointer; padding: 0 2px 6px; user-select: none; }
.time-tab.active { color: var(--theme); }
.time-tab.active::after { content: ''; position: absolute; left: 0; right: 0; bottom: 0; margin: auto; width: 22px; height: 3px; border-radius: 2px; background: var(--theme); }
.summary-area { text-align: center; padding: 12px 24px 6px; }
.date-row { display: inline-flex; align-items: center; gap: 6px; font-size: 16px; color: #666; font-weight: 500; }
.range-text { margin-top: 4px; font-size: 16px; color: #666; }
.main-row { margin-top: 2px; display: inline-flex; align-items: baseline; gap: 4px; }
.main-row__value { font-size: 48px; line-height: 1; font-weight: 500; color: #1f1f1f; letter-spacing: -1px; }
.main-row__unit { font-size: 18px; color: #333; }
.main-badge { display: inline-flex; align-items: center; justify-content: center; margin-top: 6px; padding: 3px 12px; border-radius: 18px; font-size: 12px; font-weight: 600; background: #f6ffed; color: #52c41a; }
.chart-wrap { position: relative; background: #fff; padding: 6px 16px 0; }
.chart-box { position: relative; height: 160px; }
.chart-svg { width: 100%; height: 100%; display: block; }
.chart-grid line { stroke: #ececec; stroke-width: 1; }
.chart-path { fill: none; stroke: var(--theme); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.chart-dot { fill: var(--theme); }
.y-axis-labels { position: absolute; top: 6px; right: 0; width: 56px; height: 208px; pointer-events: none; }
.y-axis-label { position: absolute; right: 0; transform: translateY(50%); font-size: 12px; color: #666; }
.x-axis { display: grid; gap: 0; padding: 8px 14px 0 0; font-size: 12px; color: #666; }
.x-axis span { text-align: center; white-space: nowrap; }
.x-axis--dense { font-size: 10px; }
.timeline-knob-row { position: relative; margin: 0; height: 24px; }
.timeline-track { position: absolute; left: 0; right: 0; top: 10px; height: 1px; background: #e8e8e8; }
.timeline-knob { position: absolute; right: 16px; top: -4px; width: 24px; height: 24px; border-radius: 50%; background: #fff; box-shadow: 0 2px 10px rgba(0,0,0,0.12); }
.card { margin: 8px 12px 0; background: #fff; border-radius: 22px; padding: 14px 12px; display: flex; gap: 0; }
.metric-row { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 6px 6px; }
.metric-left__label { font-size: 12px; color: #777; margin-bottom: 4px; }
.metric-left__value { display: flex; align-items: baseline; gap: 2px; }
.metric-left__num { font-size: 16px; font-weight: 600; color: #222; }
.metric-left__unit { font-size: 12px; color: #777; }
.metric-right { font-size: 12px; color: #777; text-align: center; }
.metric-split { width: 1px; background: #ececec; margin: 0; }
.alert-btn { margin: 8px 12px 0; border: 1.5px solid var(--theme); border-radius: 12px; padding: 11px; text-align: center; font-size: 13px; color: var(--theme); font-weight: 600; cursor: pointer; background: #fff; display: flex; align-items: center; justify-content: center; gap: 6px; }
.foot-note { padding: 10px 0 12px; text-align: center; font-size: 11px; color: #b3b3b3; }
.bottom-safe { height: 12px; flex-shrink: 0; }
</style>
</head>
<body>
<div class="phone-shell">
<div class="status-bar">
<span class="time">9:41</span>
<div class="icons">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2"><path d="M1 6l5 5 5-5"/><path d="M4 9l3 3 3-3" opacity="0.5"/></svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2"><rect x="1" y="6" width="18" height="12" rx="2"/><path d="M21 10v4"/></svg>
</div>
</div>
<div class="nav-bar">
<div class="nav-back" onclick="location.href='index.html'">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#333" stroke-width="2" stroke-linecap="round"><path d="M15 19l-7-7 7-7"/></svg>
</div>
<div class="nav-title">体温</div>
</div>
<div class="scroll-content">
<div class="top-panel">
<div class="time-tabs">
<div class="time-tab active" data-mode="day"></div>
<div class="time-tab" data-mode="week"></div>
<div class="time-tab" data-mode="month"></div>
<div class="time-tab" data-mode="year"></div>
</div>
<div class="summary-area">
<div class="date-row">
<span id="dateText">4月30日 周四</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="#666"><path d="M7 10l5 5 5-5z"/></svg>
</div>
<div class="range-text" id="rangeText">00:00-23:59</div>
<div class="main-row">
<span class="main-row__value" id="mainValue">36.5</span>
<span class="main-row__unit" id="mainUnit">°C</span>
</div>
<div class="main-badge" id="statusBadge">正常</div>
</div>
<div class="chart-wrap">
<div class="chart-box">
<svg class="chart-svg" id="chart" viewBox="0 0 358 258" preserveAspectRatio="none">
<g class="chart-grid" id="chartGrid"></g>
<path id="line" class="chart-path"></path>
<circle id="dot" class="chart-dot" r="3.2"></circle>
</svg>
<div class="y-axis-labels" id="yAxisLabels"></div>
</div>
<div class="x-axis" id="xAxis"></div>
<div class="timeline-knob-row">
<div class="timeline-track"></div>
<div class="timeline-knob"></div>
</div>
</div>
</div>
<div class="card">
<div class="metric-row">
<div class="metric-left">
<div class="metric-left__label">最高</div>
<div class="metric-left__value"><span class="metric-left__num" id="maxValue">37.1</span><span class="metric-left__unit">°C</span></div>
</div>
<div class="metric-right" id="maxHint">13:00</div>
</div>
<div class="metric-split"></div>
<div class="metric-row">
<div class="metric-left">
<div class="metric-left__label">最低</div>
<div class="metric-left__value"><span class="metric-left__num" id="minValue">36.2</span><span class="metric-left__unit">°C</span></div>
</div>
<div class="metric-right" id="minHint">04:20</div>
</div>
<div class="metric-split"></div>
<div class="metric-row">
<div class="metric-left">
<div class="metric-left__label">平均</div>
<div class="metric-left__value"><span class="metric-left__num" id="avgValue">36.6</span><span class="metric-left__unit">°C</span></div>
</div>
<div class="metric-right" id="avgHint">全天</div>
</div>
</div>
<div class="alert-btn" onclick="location.href='temperature-alerts.html'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--theme)" stroke-width="2"><path d="M12 9v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
查看预警历史
</div>
<div class="foot-note">以上数据为原型示例,实际以设备同步为准</div>
<div class="bottom-safe"></div>
</div>
</div>
<script>
var dataMap = {
day: {
dateText: '4月30日 周四',
rangeText: '00:00-23:59',
unit: '°C',
decimals: 1,
points: [36.5,36.4,36.4,36.3,36.2,36.2,36.3,36.4,36.5,36.5,36.6,36.7,36.8,37.1,36.9,36.8,36.7,36.6,36.6,36.5,36.5,36.4,36.4,36.5],
totalMode: 'last',
yTicks: [37.5, 37.0, 36.5, 36.0, 35.5],
xLabels: ['00:00','06:00','12:00','18:00','24:00'],
status: '正常',
max: { v:37.1, hint:'13:00' },
min: { v:36.2, hint:'04:20' },
avg: { v:36.6, hint:'全天' }
},
week: {
dateText: '4月27日-5月3日',
rangeText: '周一-周日',
unit: '°C',
decimals: 1,
points: [36.5, 36.6, 36.4, 36.7, 36.5, 36.8, 36.5],
totalMode: 'avg',
yTicks: [37.5, 37.0, 36.5, 36.0, 35.5],
xLabels: ['周一','周二','周三','周四','周五','周六','周日'],
status: '正常',
max: { v:36.8, hint:'周六' },
min: { v:36.4, hint:'周三' },
avg: { v:36.6, hint:'本周' }
},
month: {
dateText: '2026年4月',
rangeText: '4月1日-4月30日',
unit: '°C',
decimals: 1,
points: [36.5,36.6,36.4,36.7,36.5,36.8,36.5,36.6,36.4,36.7,36.5,36.8,36.5,36.6,36.4,36.7,36.5,36.8,36.5,36.6,36.4,36.7,36.5,36.8,36.5,36.6,36.4,36.7,36.5,36.6],
totalMode: 'avg',
yTicks: [37.5, 37.0, 36.5, 36.0, 35.5],
xLabels: ['1日','7日','14日','21日','30日'],
status: '正常',
max: { v:37.1, hint:'04-15' },
min: { v:36.2, hint:'04-08' },
avg: { v:36.6, hint:'本月' }
},
year: {
dateText: '2026年',
rangeText: '1月-12月',
unit: '°C',
decimals: 1,
points: [36.5,36.6,36.4,36.7,36.5,36.8,36.5,36.6,36.4,36.7,36.5,36.6],
totalMode: 'avg',
yTicks: [37.5, 37.0, 36.5, 36.0, 35.5],
xLabels: ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'],
status: '正常',
max: { v:37.2, hint:'03月' },
min: { v:36.2, hint:'02月' },
avg: { v:36.6, hint:'全年' }
}
};
function avg(arr) {
if (!arr.length) return 0;
var sum = arr.reduce(function(s, v){ return s + v; }, 0);
return sum / arr.length;
}
function getValue(cfg) {
if (cfg.totalMode === 'last') return cfg.points[cfg.points.length - 1];
if (cfg.totalMode === 'avg') return avg(cfg.points);
if (cfg.totalMode === 'max') return Math.max.apply(null, cfg.points);
if (cfg.totalMode === 'min') return Math.min.apply(null, cfg.points);
return cfg.points[cfg.points.length - 1];
}
function formatValue(val, decimals) {
if (typeof val !== 'number') return String(val);
if (decimals != null) return val.toFixed(decimals);
return String(val);
}
function renderChart(cfg) {
var chartWidth = 330;
var chartHeight = 208;
var startY = 22;
var stepX = chartWidth / (cfg.points.length - 1 || 1);
var maxY = cfg.yTicks[0];
var grid = document.getElementById('chartGrid');
var yAxis = document.getElementById('yAxisLabels');
var xAxis = document.getElementById('xAxis');
var lineEl = document.getElementById('line');
var dotEl = document.getElementById('dot');
grid.innerHTML = '';
cfg.yTicks.forEach(function(_, index) {
var y = startY + (chartHeight / (cfg.yTicks.length - 1)) * index;
grid.innerHTML += '<line x1="0" y1="' + y.toFixed(1) + '" x2="330" y2="' + y.toFixed(1) + '"></line>';
});
yAxis.innerHTML = cfg.yTicks.map(function(tick, index) {
return '<span class="y-axis-label" style="top:' + (index * 52) + 'px">' + formatValue(tick, 1) + '</span>';
}).join('');
var pts = cfg.points.map(function(v, i) {
var x = i * stepX;
var y = startY + chartHeight - (v / maxY) * chartHeight;
return { x: x, y: y };
});
var d = pts.map(function(p, index) {
return (index === 0 ? 'M' : 'L') + p.x.toFixed(1) + ',' + p.y.toFixed(1);
}).join(' ');
lineEl.setAttribute('d', d);
var last = pts[pts.length - 1];
dotEl.setAttribute('cx', last.x.toFixed(1));
dotEl.setAttribute('cy', last.y.toFixed(1));
xAxis.className = 'x-axis' + (cfg.xLabels.length > 7 ? ' x-axis--dense' : '');
xAxis.style.gridTemplateColumns = 'repeat(' + cfg.xLabels.length + ', 1fr)';
xAxis.innerHTML = cfg.xLabels.map(function(label) {
return '<span>' + label + '</span>';
}).join('');
}
function renderMode(mode) {
var cfg = dataMap[mode];
var value = getValue(cfg);
document.getElementById('dateText').textContent = cfg.dateText;
document.getElementById('rangeText').textContent = cfg.rangeText;
document.getElementById('mainValue').textContent = formatValue(value, cfg.decimals);
document.getElementById('mainUnit').textContent = cfg.unit;
document.getElementById('statusBadge').textContent = cfg.status;
document.getElementById('maxValue').textContent = formatValue(cfg.max.v, cfg.decimals);
document.getElementById('maxHint').textContent = cfg.max.hint;
document.getElementById('minValue').textContent = formatValue(cfg.min.v, cfg.decimals);
document.getElementById('minHint').textContent = cfg.min.hint;
document.getElementById('avgValue').textContent = formatValue(cfg.avg.v, cfg.decimals);
document.getElementById('avgHint').textContent = cfg.avg.hint;
renderChart(cfg);
}
document.querySelectorAll('.time-tab').forEach(function(tab) {
tab.onclick = function() {
document.querySelectorAll('.time-tab').forEach(function(item) { item.classList.remove('active'); });
tab.classList.add('active');
renderMode(tab.getAttribute('data-mode'));
};
});
renderMode('day');
</script>
</body>
</html>
+35 -2
View File
@@ -300,11 +300,44 @@ const SITE_MAP = [
{ name:'应用主页', dir:'employee-app/home', entry:'index.html', pages:[
{n:'应用主页(收起态)',f:'index.html'},
] },
{ name:'健康体检', dir:'employee-app/health-checkup', pages:[] },
{ name:'健康体检', dir:'employee-app/health-checkup', entry:'index.html', pages:[
{ file:'index.html', label:'健康体检主页' },
{ file:'index-inactive.html', label:'健康体检-未开展' },
{ file:'booking-type.html', label:'选择体检类型' },
{ file:'booking-hospital.html', label:'预约-选择医院' },
{ file:'booking-project.html', label:'预约-选择项目' },
{ file:'booking-project-selected.html', label:'预约-已选项目' },
{ file:'booking-date.html', label:'预约-选择日期' },
{ file:'booking-date-selected.html', label:'预约-已选日期' },
{ file:'my-appointments-detail.html', label:'预约详情' },
{ file:'my-appointments-detail-done.html', label:'预约详情-已完成' },
{ file:'my-appointments-detail-cancelled.html', label:'预约详情-已取消' },
{ file:'my-appointments.html', label:'我的预约-待体检' },
{ file:'my-appointments-all.html', label:'我的预约-全部' },
{ file:'my-appointments-done.html', label:'我的预约-已完成' },
{ file:'my-appointments-cancelled.html', label:'我的预约-已取消' },
{ file:'hospital-checkin.html', label:'到院体检' },
{ file:'reports.html', label:'体检报告' },
{ file:'report-query.html', label:'报告查询详情' },
{ file:'project-detail.html', label:'体检项目详情' }
] },
{ name:'体重管理', dir:'employee-app/weight-management', pages:[] },
{ name:'营养管理', dir:'employee-app/nutrition', pages:[] },
{ name:'运动管理', dir:'employee-app/exercise', pages:[] },
{ name:'健康监测', dir:'employee-app/health-monitor', pages:[] },
{ name:'健康监测', dir:'employee-app/health-monitor', entry:'index.html', pages:[
{ file:'index.html', label:'健康监测首页' },
{ file:'steps.html', label:'步数详情' },
{ file:'sleep.html', label:'睡眠详情' },
{ file:'heart-rate.html', label:'心率详情' },
{ file:'heart-rate-alerts.html', label:'心率预警历史' },
{ file:'temperature.html', label:'体温详情' },
{ file:'temperature-alerts.html', label:'体温预警历史' },
{ file:'blood-oxygen.html', label:'血氧详情' },
{ file:'blood-oxygen-alerts.html', label:'血氧预警历史' },
{ file:'stress.html', label:'压力详情' },
{ file:'stress-alerts.html', label:'压力预警历史' },
{ file:'exercise.html', label:'锻炼详情' }
] },
{ name:'健康评估', dir:'employee-app/health-assessment', pages:[] },
{ name:'心脑血管病预防', dir:'employee-app/cardiovascular', pages:[] },
{ name:'糖尿病预防', dir:'employee-app/diabetes', pages:[] },
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../chrome-devtools-mcp/build/src/bin/chrome-devtools.js" "$@"
else
exec node "$basedir/../chrome-devtools-mcp/build/src/bin/chrome-devtools.js" "$@"
fi
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../chrome-devtools-mcp/build/src/bin/chrome-devtools-mcp.js" "$@"
else
exec node "$basedir/../chrome-devtools-mcp/build/src/bin/chrome-devtools-mcp.js" "$@"
fi
+17
View File
@@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\chrome-devtools-mcp\build\src\bin\chrome-devtools-mcp.js" %*
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../chrome-devtools-mcp/build/src/bin/chrome-devtools-mcp.js" $args
} else {
& "$basedir/node$exe" "$basedir/../chrome-devtools-mcp/build/src/bin/chrome-devtools-mcp.js" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../chrome-devtools-mcp/build/src/bin/chrome-devtools-mcp.js" $args
} else {
& "node$exe" "$basedir/../chrome-devtools-mcp/build/src/bin/chrome-devtools-mcp.js" $args
}
$ret=$LASTEXITCODE
}
exit $ret
+17
View File
@@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\chrome-devtools-mcp\build\src\bin\chrome-devtools.js" %*
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../chrome-devtools-mcp/build/src/bin/chrome-devtools.js" $args
} else {
& "$basedir/node$exe" "$basedir/../chrome-devtools-mcp/build/src/bin/chrome-devtools.js" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../chrome-devtools-mcp/build/src/bin/chrome-devtools.js" $args
} else {
& "node$exe" "$basedir/../chrome-devtools-mcp/build/src/bin/chrome-devtools.js" $args
}
$ret=$LASTEXITCODE
}
exit $ret
+15
View File
@@ -3,10 +3,24 @@
"lockfileVersion": 3,
"requires": true,
"packages": {
"node_modules/chrome-devtools-mcp": {
"version": "0.21.0",
"resolved": "https://registry.npmjs.org/chrome-devtools-mcp/-/chrome-devtools-mcp-0.21.0.tgz",
"integrity": "sha512-d+iqrRmcwpRFV3Q4DRCF2LCoq+WCRU3GhISKQ9v8g+1C2Uh8upj3urkjxNO4QIjhBMIYei/VQ1OQLFceby80Og==",
"license": "Apache-2.0",
"bin": {
"chrome-devtools": "build/src/bin/chrome-devtools.js",
"chrome-devtools-mcp": "build/src/bin/chrome-devtools-mcp.js"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
@@ -25,6 +39,7 @@
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+770
View File
@@ -0,0 +1,770 @@
# Chrome DevTools MCP
[![npm chrome-devtools-mcp package](https://img.shields.io/npm/v/chrome-devtools-mcp.svg)](https://npmjs.org/package/chrome-devtools-mcp)
`chrome-devtools-mcp` lets your coding agent (such as Gemini, Claude, Cursor or Copilot)
control and inspect a live Chrome browser. It acts as a Model-Context-Protocol
(MCP) server, giving your AI coding assistant access to the full power of
Chrome DevTools for reliable automation, in-depth debugging, and performance analysis.
## [Tool reference](./docs/tool-reference.md) | [Changelog](./CHANGELOG.md) | [Contributing](./CONTRIBUTING.md) | [Troubleshooting](./docs/troubleshooting.md) | [Design Principles](./docs/design-principles.md)
## Key features
- **Get performance insights**: Uses [Chrome
DevTools](https://github.com/ChromeDevTools/devtools-frontend) to record
traces and extract actionable performance insights.
- **Advanced browser debugging**: Analyze network requests, take screenshots and
check browser console messages (with source-mapped stack traces).
- **Reliable automation**. Uses
[puppeteer](https://github.com/puppeteer/puppeteer) to automate actions in
Chrome and automatically wait for action results.
## Disclaimers
`chrome-devtools-mcp` exposes content of the browser instance to the MCP clients
allowing them to inspect, debug, and modify any data in the browser or DevTools.
Avoid sharing sensitive or personal information that you don't want to share with
MCP clients.
`chrome-devtools-mcp` officially supports Google Chrome and [Chrome for Testing](https://developer.chrome.com/blog/chrome-for-testing/) only.
Other Chromium-based browser may work, but this is not guaranteed, and you may encounter unexpected behavior. Use at your own discretion.
We are committed to providing fixes and support for the latest version of [Extended Stable Chrome](https://chromiumdash.appspot.com/schedule).
Performance tools may send trace URLs to the Google CrUX API to fetch real-user
experience data. This helps provide a holistic performance picture by
presenting field data alongside lab data. This data is collected by the [Chrome
User Experience Report (CrUX)](https://developer.chrome.com/docs/crux). To disable
this, run with the `--no-performance-crux` flag.
## **Usage statistics**
Google collects usage statistics (such as tool invocation success rates, latency, and environment information) to improve the reliability and performance of Chrome DevTools MCP.
Data collection is **enabled by default**. You can opt-out by passing the `--no-usage-statistics` flag when starting the server:
```json
"args": ["-y", "chrome-devtools-mcp@latest", "--no-usage-statistics"]
```
Google handles this data in accordance with the [Google Privacy Policy](https://policies.google.com/privacy).
Google's collection of usage statistics for Chrome DevTools MCP is independent from the Chrome browser's usage statistics. Opting out of Chrome metrics does not automatically opt you out of this tool, and vice-versa.
Collection is disabled if CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS or CI env variables are set.
## Requirements
- [Node.js](https://nodejs.org/) v20.19 or a newer [latest maintenance LTS](https://github.com/nodejs/Release#release-schedule) version.
- [Chrome](https://www.google.com/chrome/) current stable version or newer.
- [npm](https://www.npmjs.com/)
## Getting started
Add the following config to your MCP client:
```json
{
"mcpServers": {
"chrome-devtools": {
"command": "npx",
"args": ["-y", "chrome-devtools-mcp@latest"]
}
}
}
```
> [!NOTE]
> Using `chrome-devtools-mcp@latest` ensures that your MCP client will always use the latest version of the Chrome DevTools MCP server.
If you are interested in doing only basic browser tasks, use the `--slim` mode:
```json
{
"mcpServers": {
"chrome-devtools": {
"command": "npx",
"args": ["-y", "chrome-devtools-mcp@latest", "--slim", "--headless"]
}
}
}
```
See [Slim tool reference](./docs/slim-tool-reference.md).
### MCP Client configuration
<details>
<summary>Amp</summary>
Follow https://ampcode.com/manual#mcp and use the config provided above. You can also install the Chrome DevTools MCP server using the CLI:
```bash
amp mcp add chrome-devtools -- npx chrome-devtools-mcp@latest
```
</details>
<details>
<summary>Antigravity</summary>
To use the Chrome DevTools MCP server follow the instructions from <a href="https://antigravity.google/docs/mcp">Antigravity's docs</a> to install a custom MCP server. Add the following config to the MCP servers config:
```bash
{
"mcpServers": {
"chrome-devtools": {
"command": "npx",
"args": [
"chrome-devtools-mcp@latest",
"--browser-url=http://127.0.0.1:9222",
"-y"
]
}
}
}
```
This will make the Chrome DevTools MCP server automatically connect to the browser that Antigravity is using. If you are not using port 9222, make sure to adjust accordingly.
Chrome DevTools MCP will not start the browser instance automatically using this approach because the Chrome DevTools MCP server connects to Antigravity's built-in browser. If the browser is not already running, you have to start it first by clicking the Chrome icon at the top right corner.
</details>
<details>
<summary>Claude Code</summary>
**Install via CLI (MCP only)**
Use the Claude Code CLI to add the Chrome DevTools MCP server (<a href="https://code.claude.com/docs/en/mcp">guide</a>):
```bash
claude mcp add chrome-devtools --scope user npx chrome-devtools-mcp@latest
```
**Install as a Plugin (MCP + Skills)**
> [!NOTE]
> If you already had Chrome DevTools MCP installed previously for Claude Code, make sure to remove it first from your installation and configuration files.
To install Chrome DevTools MCP with skills, add the marketplace registry in Claude Code:
```sh
/plugin marketplace add ChromeDevTools/chrome-devtools-mcp
```
Then, install the plugin:
```sh
/plugin install chrome-devtools-mcp
```
Restart Claude Code to have the MCP server and skills load (check with `/skills`).
> [!TIP]
> If the plugin installation fails with a `Failed to clone repository` error (e.g., HTTPS connectivity issues behind a corporate firewall), see the [troubleshooting guide](./docs/troubleshooting.md#claude-code-plugin-installation-fails-with-failed-to-clone-repository) for workarounds, or use the CLI installation method above instead.
</details>
<details>
<summary>Cline</summary>
Follow https://docs.cline.bot/mcp/configuring-mcp-servers and use the config provided above.
</details>
<details>
<summary>Codex</summary>
Follow the <a href="https://developers.openai.com/codex/mcp/#configure-with-the-cli">configure MCP guide</a>
using the standard config from above. You can also install the Chrome DevTools MCP server using the Codex CLI:
```bash
codex mcp add chrome-devtools -- npx chrome-devtools-mcp@latest
```
**On Windows 11**
Configure the Chrome install location and increase the startup timeout by updating `.codex/config.toml` and adding the following `env` and `startup_timeout_ms` parameters:
```
[mcp_servers.chrome-devtools]
command = "cmd"
args = [
"/c",
"npx",
"-y",
"chrome-devtools-mcp@latest",
]
env = { SystemRoot="C:\\Windows", PROGRAMFILES="C:\\Program Files" }
startup_timeout_ms = 20_000
```
</details>
<details>
<summary>Command Code</summary>
Use the Command Code CLI to add the Chrome DevTools MCP server (<a href="https://commandcode.ai/docs/mcp">MCP guide</a>):
```bash
cmd mcp add chrome-devtools --scope user npx chrome-devtools-mcp@latest
```
</details>
<details>
<summary>Copilot CLI</summary>
Start Copilot CLI:
```
copilot
```
Start the dialog to add a new MCP server by running:
```
/mcp add
```
Configure the following fields and press `CTRL+S` to save the configuration:
- **Server name:** `chrome-devtools`
- **Server Type:** `[1] Local`
- **Command:** `npx -y chrome-devtools-mcp@latest`
</details>
<details>
<summary>Copilot / VS Code</summary>
**Click the button to install:**
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](https://vscode.dev/redirect/mcp/install?name=io.github.ChromeDevTools%2Fchrome-devtools-mcp&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22chrome-devtools-mcp%22%5D%2C%22env%22%3A%7B%7D%7D)
[<img src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5" alt="Install in VS Code Insiders">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522io.github.ChromeDevTools%252Fchrome-devtools-mcp%2522%252C%2522config%2522%253A%257B%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522chrome-devtools-mcp%2522%255D%252C%2522env%2522%253A%257B%257D%257D%257D)
**Or install manually:**
Follow the MCP install <a href="https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_add-an-mcp-server">guide</a>,
with the standard config from above. You can also install the Chrome DevTools MCP server using the VS Code CLI:
For macOS and Linux:
```bash
code --add-mcp '{"name":"io.github.ChromeDevTools/chrome-devtools-mcp","command":"npx","args":["-y","chrome-devtools-mcp"],"env":{}}'
```
For Windows (PowerShell):
```powershell
code --add-mcp '{"""name""":"""io.github.ChromeDevTools/chrome-devtools-mcp""","""command""":"""npx""","""args""":["""-y""","""chrome-devtools-mcp"""]}'
```
</details>
<details>
<summary>Cursor</summary>
**Click the button to install:**
[<img src="https://cursor.com/deeplink/mcp-install-dark.svg" alt="Install in Cursor">](https://cursor.com/en/install-mcp?name=chrome-devtools&config=eyJjb21tYW5kIjoibnB4IC15IGNocm9tZS1kZXZ0b29scy1tY3BAbGF0ZXN0In0%3D)
**Or install manually:**
Go to `Cursor Settings` -> `MCP` -> `New MCP Server`. Use the config provided above.
</details>
<details>
<summary>Factory CLI</summary>
Use the Factory CLI to add the Chrome DevTools MCP server (<a href="https://docs.factory.ai/cli/configuration/mcp">guide</a>):
```bash
droid mcp add chrome-devtools "npx -y chrome-devtools-mcp@latest"
```
</details>
<details>
<summary>Gemini CLI</summary>
Install the Chrome DevTools MCP server using the Gemini CLI.
**Project wide:**
```bash
# Either MCP only:
gemini mcp add chrome-devtools npx chrome-devtools-mcp@latest
# Or as a Gemini extension (MCP+Skills):
gemini extensions install --auto-update https://github.com/ChromeDevTools/chrome-devtools-mcp
```
**Globally:**
```bash
gemini mcp add -s user chrome-devtools npx chrome-devtools-mcp@latest
```
Alternatively, follow the <a href="https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md#how-to-set-up-your-mcp-server">MCP guide</a> and use the standard config from above.
</details>
<details>
<summary>Gemini Code Assist</summary>
Follow the <a href="https://cloud.google.com/gemini/docs/codeassist/use-agentic-chat-pair-programmer#configure-mcp-servers">configure MCP guide</a>
using the standard config from above.
</details>
<details>
<summary>JetBrains AI Assistant & Junie</summary>
Go to `Settings | Tools | AI Assistant | Model Context Protocol (MCP)` -> `Add`. Use the config provided above.
The same way chrome-devtools-mcp can be configured for JetBrains Junie in `Settings | Tools | Junie | MCP Settings` -> `Add`. Use the config provided above.
</details>
<details>
<summary>Kiro</summary>
In **Kiro Settings**, go to `Configure MCP` > `Open Workspace or User MCP Config` > Use the configuration snippet provided above.
Or, from the IDE **Activity Bar** > `Kiro` > `MCP Servers` > `Click Open MCP Config`. Use the configuration snippet provided above.
</details>
<details>
<summary>Katalon Studio</summary>
The Chrome DevTools MCP server can be used with <a href="https://docs.katalon.com/katalon-studio/studioassist/mcp-servers/setting-up-chrome-devtools-mcp-server-for-studioassist">Katalon StudioAssist</a> via an MCP proxy.
**Step 1:** Install the MCP proxy by following the <a href="https://docs.katalon.com/katalon-studio/studioassist/mcp-servers/setting-up-mcp-proxy-for-stdio-mcp-servers">MCP proxy setup guide</a>.
**Step 2:** Start the Chrome DevTools MCP server with the proxy:
```bash
mcp-proxy --transport streamablehttp --port 8080 -- npx -y chrome-devtools-mcp@latest
```
**Note:** You may need to pick another port if 8080 is already in use.
**Step 3:** In Katalon Studio, add the server to StudioAssist with the following settings:
- **Connection URL:** `http://127.0.0.1:8080/mcp`
- **Transport type:** `HTTP`
Once connected, the Chrome DevTools MCP tools will be available in StudioAssist.
</details>
<details>
<summary>OpenCode</summary>
Add the following configuration to your `opencode.json` file. If you don't have one, create it at `~/.config/opencode/opencode.json` (<a href="https://opencode.ai/docs/mcp-servers">guide</a>):
```json
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"chrome-devtools": {
"type": "local",
"command": ["npx", "-y", "chrome-devtools-mcp@latest"]
}
}
}
```
</details>
<details>
<summary>Qoder</summary>
In **Qoder Settings**, go to `MCP Server` > `+ Add` > Use the configuration snippet provided above.
Alternatively, follow the <a href="https://docs.qoder.com/user-guide/chat/model-context-protocol">MCP guide</a> and use the standard config from above.
</details>
<details>
<summary>Qoder CLI</summary>
Install the Chrome DevTools MCP server using the Qoder CLI (<a href="https://docs.qoder.com/cli/using-cli#mcp-servers">guide</a>):
**Project wide:**
```bash
qodercli mcp add chrome-devtools -- npx chrome-devtools-mcp@latest
```
**Globally:**
```bash
qodercli mcp add -s user chrome-devtools -- npx chrome-devtools-mcp@latest
```
</details>
<details>
<summary>Visual Studio</summary>
**Click the button to install:**
[<img src="https://img.shields.io/badge/Visual_Studio-Install-C16FDE?logo=visualstudio&logoColor=white" alt="Install in Visual Studio">](https://vs-open.link/mcp-install?%7B%22name%22%3A%22chrome-devtools%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22chrome-devtools-mcp%40latest%22%5D%7D)
</details>
<details>
<summary>Warp</summary>
Go to `Settings | AI | Manage MCP Servers` -> `+ Add` to [add an MCP Server](https://docs.warp.dev/knowledge-and-collaboration/mcp#adding-an-mcp-server). Use the config provided above.
</details>
<details>
<summary>Windsurf</summary>
Follow the <a href="https://docs.windsurf.com/windsurf/cascade/mcp#mcp-config-json">configure MCP guide</a>
using the standard config from above.
</details>
### Your first prompt
Enter the following prompt in your MCP Client to check if everything is working:
```
Check the performance of https://developers.chrome.com
```
Your MCP client should open the browser and record a performance trace.
> [!NOTE]
> The MCP server will start the browser automatically once the MCP client uses a tool that requires a running browser instance. Connecting to the Chrome DevTools MCP server on its own will not automatically start the browser.
## Tools
If you run into any issues, checkout our [troubleshooting guide](./docs/troubleshooting.md).
<!-- BEGIN AUTO GENERATED TOOLS -->
- **Input automation** (9 tools)
- [`click`](docs/tool-reference.md#click)
- [`drag`](docs/tool-reference.md#drag)
- [`fill`](docs/tool-reference.md#fill)
- [`fill_form`](docs/tool-reference.md#fill_form)
- [`handle_dialog`](docs/tool-reference.md#handle_dialog)
- [`hover`](docs/tool-reference.md#hover)
- [`press_key`](docs/tool-reference.md#press_key)
- [`type_text`](docs/tool-reference.md#type_text)
- [`upload_file`](docs/tool-reference.md#upload_file)
- **Navigation automation** (6 tools)
- [`close_page`](docs/tool-reference.md#close_page)
- [`list_pages`](docs/tool-reference.md#list_pages)
- [`navigate_page`](docs/tool-reference.md#navigate_page)
- [`new_page`](docs/tool-reference.md#new_page)
- [`select_page`](docs/tool-reference.md#select_page)
- [`wait_for`](docs/tool-reference.md#wait_for)
- **Emulation** (2 tools)
- [`emulate`](docs/tool-reference.md#emulate)
- [`resize_page`](docs/tool-reference.md#resize_page)
- **Performance** (4 tools)
- [`performance_analyze_insight`](docs/tool-reference.md#performance_analyze_insight)
- [`performance_start_trace`](docs/tool-reference.md#performance_start_trace)
- [`performance_stop_trace`](docs/tool-reference.md#performance_stop_trace)
- [`take_memory_snapshot`](docs/tool-reference.md#take_memory_snapshot)
- **Network** (2 tools)
- [`get_network_request`](docs/tool-reference.md#get_network_request)
- [`list_network_requests`](docs/tool-reference.md#list_network_requests)
- **Debugging** (6 tools)
- [`evaluate_script`](docs/tool-reference.md#evaluate_script)
- [`get_console_message`](docs/tool-reference.md#get_console_message)
- [`lighthouse_audit`](docs/tool-reference.md#lighthouse_audit)
- [`list_console_messages`](docs/tool-reference.md#list_console_messages)
- [`take_screenshot`](docs/tool-reference.md#take_screenshot)
- [`take_snapshot`](docs/tool-reference.md#take_snapshot)
<!-- END AUTO GENERATED TOOLS -->
## Configuration
The Chrome DevTools MCP server supports the following configuration option:
<!-- BEGIN AUTO GENERATED OPTIONS -->
- **`--autoConnect`/ `--auto-connect`**
If specified, automatically connects to a browser (Chrome 144+) running locally from the user data directory identified by the channel param (default channel is stable). Requires the remoted debugging server to be started in the Chrome instance via chrome://inspect/#remote-debugging.
- **Type:** boolean
- **Default:** `false`
- **`--browserUrl`/ `--browser-url`, `-u`**
Connect to a running, debuggable Chrome instance (e.g. `http://127.0.0.1:9222`). For more details see: https://github.com/ChromeDevTools/chrome-devtools-mcp#connecting-to-a-running-chrome-instance.
- **Type:** string
- **`--wsEndpoint`/ `--ws-endpoint`, `-w`**
WebSocket endpoint to connect to a running Chrome instance (e.g., ws://127.0.0.1:9222/devtools/browser/<id>). Alternative to --browserUrl.
- **Type:** string
- **`--wsHeaders`/ `--ws-headers`**
Custom headers for WebSocket connection in JSON format (e.g., '{"Authorization":"Bearer token"}'). Only works with --wsEndpoint.
- **Type:** string
- **`--headless`**
Whether to run in headless (no UI) mode.
- **Type:** boolean
- **Default:** `false`
- **`--executablePath`/ `--executable-path`, `-e`**
Path to custom Chrome executable.
- **Type:** string
- **`--isolated`**
If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed. Defaults to false.
- **Type:** boolean
- **`--userDataDir`/ `--user-data-dir`**
Path to the user data directory for Chrome. Default is $HOME/.cache/chrome-devtools-mcp/chrome-profile$CHANNEL_SUFFIX_IF_NON_STABLE
- **Type:** string
- **`--channel`**
Specify a different Chrome channel that should be used. The default is the stable channel version.
- **Type:** string
- **Choices:** `stable`, `canary`, `beta`, `dev`
- **`--logFile`/ `--log-file`**
Path to a file to write debug logs to. Set the env variable `DEBUG` to `*` to enable verbose logs. Useful for submitting bug reports.
- **Type:** string
- **`--viewport`**
Initial viewport size for the Chrome instances started by the server. For example, `1280x720`. In headless mode, max size is 3840x2160px.
- **Type:** string
- **`--proxyServer`/ `--proxy-server`**
Proxy server configuration for Chrome passed as --proxy-server when launching the browser. See https://www.chromium.org/developers/design-documents/network-settings/ for details.
- **Type:** string
- **`--acceptInsecureCerts`/ `--accept-insecure-certs`**
If enabled, ignores errors relative to self-signed and expired certificates. Use with caution.
- **Type:** boolean
- **`--experimentalScreencast`/ `--experimental-screencast`**
Exposes experimental screencast tools (requires ffmpeg). Install ffmpeg https://www.ffmpeg.org/download.html and ensure it is available in the MCP server PATH.
- **Type:** boolean
- **`--chromeArg`/ `--chrome-arg`**
Additional arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.
- **Type:** array
- **`--ignoreDefaultChromeArg`/ `--ignore-default-chrome-arg`**
Explicitly disable default arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.
- **Type:** array
- **`--categoryEmulation`/ `--category-emulation`**
Set to false to exclude tools related to emulation.
- **Type:** boolean
- **Default:** `true`
- **`--categoryPerformance`/ `--category-performance`**
Set to false to exclude tools related to performance.
- **Type:** boolean
- **Default:** `true`
- **`--categoryNetwork`/ `--category-network`**
Set to false to exclude tools related to network.
- **Type:** boolean
- **Default:** `true`
- **`--performanceCrux`/ `--performance-crux`**
Set to false to disable sending URLs from performance traces to CrUX API to get field performance data.
- **Type:** boolean
- **Default:** `true`
- **`--usageStatistics`/ `--usage-statistics`**
Set to false to opt-out of usage statistics collection. Google collects usage data to improve the tool, handled under the Google Privacy Policy (https://policies.google.com/privacy). This is independent from Chrome browser metrics. Disabled if CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS or CI env variables are set.
- **Type:** boolean
- **Default:** `true`
- **`--slim`**
Exposes a "slim" set of 3 tools covering navigation, script execution and screenshots only. Useful for basic browser tasks.
- **Type:** boolean
<!-- END AUTO GENERATED OPTIONS -->
Pass them via the `args` property in the JSON configuration. For example:
```json
{
"mcpServers": {
"chrome-devtools": {
"command": "npx",
"args": [
"chrome-devtools-mcp@latest",
"--channel=canary",
"--headless=true",
"--isolated=true"
]
}
}
}
```
### Connecting via WebSocket with custom headers
You can connect directly to a Chrome WebSocket endpoint and include custom headers (e.g., for authentication):
```json
{
"mcpServers": {
"chrome-devtools": {
"command": "npx",
"args": [
"chrome-devtools-mcp@latest",
"--wsEndpoint=ws://127.0.0.1:9222/devtools/browser/<id>",
"--wsHeaders={\"Authorization\":\"Bearer YOUR_TOKEN\"}"
]
}
}
}
```
To get the WebSocket endpoint from a running Chrome instance, visit `http://127.0.0.1:9222/json/version` and look for the `webSocketDebuggerUrl` field.
You can also run `npx chrome-devtools-mcp@latest --help` to see all available configuration options.
## Concepts
### User data directory
`chrome-devtools-mcp` starts a Chrome's stable channel instance using the following user
data directory:
- Linux / macOS: `$HOME/.cache/chrome-devtools-mcp/chrome-profile-$CHANNEL`
- Windows: `%HOMEPATH%/.cache/chrome-devtools-mcp/chrome-profile-$CHANNEL`
The user data directory is not cleared between runs and shared across
all instances of `chrome-devtools-mcp`. Set the `isolated` option to `true`
to use a temporary user data dir instead which will be cleared automatically after
the browser is closed.
### Connecting to a running Chrome instance
By default, the Chrome DevTools MCP server will start a new Chrome instance with a dedicated profile. This might not be ideal in all situations:
- If you would like to maintain the same application state when alternating between manual site testing and agent-driven testing.
- When the MCP needs to sign into a website. Some accounts may prevent sign-in when the browser is controlled via WebDriver (the default launch mechanism for the Chrome DevTools MCP server).
- If you're running your LLM inside a sandboxed environment, but you would like to connect to a Chrome instance that runs outside the sandbox.
In these cases, start Chrome first and let the Chrome DevTools MCP server connect to it. There are two ways to do so:
- **Automatic connection (available in Chrome 144)**: best for sharing state between manual and agent-driven testing.
- **Manual connection via remote debugging port**: best when running inside a sandboxed environment.
#### Automatically connecting to a running Chrome instance
**Step 1:** Set up remote debugging in Chrome
In Chrome (\>= M144), do the following to set up remote debugging:
1. Navigate to `chrome://inspect/#remote-debugging` to enable remote debugging.
2. Follow the dialog UI to allow or disallow incoming debugging connections.
**Step 2:** Configure Chrome DevTools MCP server to automatically connect to a running Chrome Instance
To connect the `chrome-devtools-mcp` server to the running Chrome instance, use
`--autoConnect` command line argument for the MCP server.
The following code snippet is an example configuration for gemini-cli:
```json
{
"mcpServers": {
"chrome-devtools": {
"command": "npx",
"args": ["chrome-devtools-mcp@latest", "--autoConnect"]
}
}
}
```
**Step 3:** Test your setup
Make sure your browser is running. Open gemini-cli and run the following prompt:
```none
Check the performance of https://developers.chrome.com
```
> [!NOTE]
> The <code>autoConnect</code> option requires the user to start Chrome. If the user has multiple active profiles, the MCP server will connect to the default profile (as determined by Chrome). The MCP server has access to all open windows for the selected profile.
The Chrome DevTools MCP server will try to connect to your running Chrome
instance. It shows a dialog asking for user permission.
Clicking **Allow** results in the Chrome DevTools MCP server opening
[developers.chrome.com](http://developers.chrome.com) and taking a performance
trace.
#### Manual connection using port forwarding
You can connect to a running Chrome instance by using the `--browser-url` option. This is useful if you are running the MCP server in a sandboxed environment that does not allow starting a new Chrome instance.
Here is a step-by-step guide on how to connect to a running Chrome instance:
**Step 1: Configure the MCP client**
Add the `--browser-url` option to your MCP client configuration. The value of this option should be the URL of the running Chrome instance. `http://127.0.0.1:9222` is a common default.
```json
{
"mcpServers": {
"chrome-devtools": {
"command": "npx",
"args": [
"chrome-devtools-mcp@latest",
"--browser-url=http://127.0.0.1:9222"
]
}
}
}
```
**Step 2: Start the Chrome browser**
> [!WARNING]
> Enabling the remote debugging port opens up a debugging port on the running browser instance. Any application on your machine can connect to this port and control the browser. Make sure that you are not browsing any sensitive websites while the debugging port is open.
Start the Chrome browser with the remote debugging port enabled. Make sure to close any running Chrome instances before starting a new one with the debugging port enabled. The port number you choose must be the same as the one you specified in the `--browser-url` option in your MCP client configuration.
For security reasons, [Chrome requires you to use a non-default user data directory](https://developer.chrome.com/blog/remote-debugging-port) when enabling the remote debugging port. You can specify a custom directory using the `--user-data-dir` flag. This ensures that your regular browsing profile and data are not exposed to the debugging session.
**macOS**
```bash
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-profile-stable
```
**Linux**
```bash
/usr/bin/google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-profile-stable
```
**Windows**
```bash
"C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222 --user-data-dir="%TEMP%\chrome-profile-stable"
```
**Step 3: Test your setup**
After configuring the MCP client and starting the Chrome browser, you can test your setup by running a simple prompt in your MCP client:
```
Check the performance of https://developers.chrome.com
```
Your MCP client should connect to the running Chrome instance and receive a performance report.
If you hit VM-to-host port forwarding issues, see the “Remote debugging between virtual machine (VM) and host fails” section in [`docs/troubleshooting.md`](./docs/troubleshooting.md#remote-debugging-between-virtual-machine-vm-and-host-fails).
For more details on remote debugging, see the [Chrome DevTools documentation](https://developer.chrome.com/docs/devtools/remote-debugging/).
### Debugging Chrome on Android
Please consult [these instructions](./docs/debugging-android.md).
## Known limitations
See [Troubleshooting](./docs/troubleshooting.md).
@@ -0,0 +1,69 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { CDPSessionEvent } from './third_party/index.js';
/**
* This class makes a puppeteer connection look like DevTools CDPConnection.
*
* Since we connect "root" DevTools targets to specific pages, we scope everything to a puppeteer CDP session.
*
* We don't have to recursively listen for 'sessionattached' as the "root" CDP session sees all child session attached
* events, regardless how deeply nested they are.
*/
export class PuppeteerDevToolsConnection {
#connection;
#observers = new Set();
#sessionEventHandlers = new Map();
constructor(session) {
this.#connection = session.connection();
session.on(CDPSessionEvent.SessionAttached, this.#startForwardingCdpEvents.bind(this));
session.on(CDPSessionEvent.SessionDetached, this.#stopForwardingCdpEvents.bind(this));
this.#startForwardingCdpEvents(session);
}
send(method, params, sessionId) {
if (sessionId === undefined) {
throw new Error('Attempting to send on the root session. This must not happen');
}
const session = this.#connection.session(sessionId);
if (!session) {
throw new Error('Unknown session ' + sessionId);
}
// Rolled protocol version between puppeteer and DevTools doesn't necessarily match
/* eslint-disable @typescript-eslint/no-explicit-any */
return session
.send(method, params)
.then(result => ({ result }))
.catch(error => ({ error }));
/* eslint-enable @typescript-eslint/no-explicit-any */
}
observe(observer) {
this.#observers.add(observer);
}
unobserve(observer) {
this.#observers.delete(observer);
}
#startForwardingCdpEvents(session) {
const handler = this.#handleEvent.bind(this, session.id());
this.#sessionEventHandlers.set(session.id(), handler);
session.on('*', handler);
}
#stopForwardingCdpEvents(session) {
const handler = this.#sessionEventHandlers.get(session.id());
if (handler) {
session.off('*', handler);
}
}
#handleEvent(sessionId, type, event) {
if (typeof type === 'string' &&
type !== CDPSessionEvent.SessionAttached &&
type !== CDPSessionEvent.SessionDetached) {
this.#observers.forEach(observer => observer.onEvent({
method: type,
sessionId,
params: event,
}));
}
}
}
+294
View File
@@ -0,0 +1,294 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { PuppeteerDevToolsConnection } from './DevToolsConnectionAdapter.js';
import { Mutex } from './Mutex.js';
import { DevTools } from './third_party/index.js';
/**
* A mock implementation of an issues manager that only implements the methods
* that are actually used by the IssuesAggregator
*/
export class FakeIssuesManager extends DevTools.Common.ObjectWrapper
.ObjectWrapper {
issues() {
return [];
}
}
// DevTools CDP errors can get noisy.
DevTools.ProtocolClient.InspectorBackend.test.suppressRequestErrors = true;
DevTools.I18n.DevToolsLocale.DevToolsLocale.instance({
create: true,
data: {
navigatorLanguage: 'en-US',
settingLanguage: 'en-US',
lookupClosestDevToolsLocale: l => l,
},
});
DevTools.I18n.i18n.registerLocaleDataForTest('en-US', {});
DevTools.Formatter.FormatterWorkerPool.FormatterWorkerPool.instance({
forceNew: true,
entrypointURL: import.meta
.resolve('./third_party/devtools-formatter-worker.js'),
});
export class UniverseManager {
#browser;
#createUniverseFor;
#universes = new WeakMap();
/** Guard access to #universes so we don't create unnecessary universes */
#mutex = new Mutex();
constructor(browser, factory = DEFAULT_FACTORY) {
this.#browser = browser;
this.#createUniverseFor = factory;
}
async init(pages) {
try {
await this.#mutex.acquire();
const promises = [];
for (const page of pages) {
promises.push(this.#createUniverseFor(page).then(targetUniverse => this.#universes.set(page, targetUniverse)));
}
this.#browser.on('targetcreated', this.#onTargetCreated);
this.#browser.on('targetdestroyed', this.#onTargetDestroyed);
await Promise.all(promises);
}
finally {
this.#mutex.release();
}
}
get(page) {
return this.#universes.get(page) ?? null;
}
dispose() {
this.#browser.off('targetcreated', this.#onTargetCreated);
this.#browser.off('targetdestroyed', this.#onTargetDestroyed);
}
#onTargetCreated = async (target) => {
const page = await target.page();
try {
await this.#mutex.acquire();
if (!page || this.#universes.has(page)) {
return;
}
this.#universes.set(page, await this.#createUniverseFor(page));
}
finally {
this.#mutex.release();
}
};
#onTargetDestroyed = async (target) => {
const page = await target.page();
try {
await this.#mutex.acquire();
if (!page || !this.#universes.has(page)) {
return;
}
this.#universes.delete(page);
}
finally {
this.#mutex.release();
}
};
}
const DEFAULT_FACTORY = async (page) => {
const settingStorage = new DevTools.Common.Settings.SettingsStorage({});
const universe = new DevTools.Foundation.Universe.Universe({
settingsCreationOptions: {
syncedStorage: settingStorage,
globalStorage: settingStorage,
localStorage: settingStorage,
settingRegistrations: DevTools.Common.SettingRegistration.getRegisteredSettings(),
},
overrideAutoStartModels: new Set([DevTools.DebuggerModel]),
});
const session = await page.createCDPSession();
const connection = new PuppeteerDevToolsConnection(session);
const targetManager = universe.context.get(DevTools.TargetManager);
targetManager.observeModels(DevTools.DebuggerModel, SKIP_ALL_PAUSES);
const target = targetManager.createTarget('main', '', 'frame', // eslint-disable-line @typescript-eslint/no-explicit-any
/* parentTarget */ null, session.id(), undefined, connection);
return { target, universe };
};
// We don't want to pause any DevTools universe session ever on the MCP side.
//
// Note that calling `setSkipAllPauses` only affects the session on which it was
// sent. This means DevTools can still pause, step and do whatever. We just won't
// see the `Debugger.paused`/`Debugger.resumed` events on the MCP side.
const SKIP_ALL_PAUSES = {
modelAdded(model) {
void model.agent.invoke_setSkipAllPauses({ skip: true });
},
modelRemoved() {
// Do nothing.
},
};
/**
* Constructed from Runtime.ExceptionDetails of an uncaught error.
*
* TODO: Also construct from a RemoteObject of subtype 'error'.
*
* Consists of the message, a fully resolved stack trace and a fully resolved 'cause' chain.
*/
export class SymbolizedError {
message;
stackTrace;
cause;
constructor(message, stackTrace, cause) {
this.message = message;
this.stackTrace = stackTrace;
this.cause = cause;
}
static async fromDetails(opts) {
const message = SymbolizedError.#getMessage(opts.details);
if (!opts.includeStackAndCause || !opts.devTools) {
return new SymbolizedError(message, opts.resolvedStackTraceForTesting, opts.resolvedCauseForTesting);
}
let stackTrace;
if (opts.resolvedStackTraceForTesting) {
stackTrace = opts.resolvedStackTraceForTesting;
}
else if (opts.details.stackTrace) {
try {
stackTrace = await createStackTrace(opts.devTools, opts.details.stackTrace, opts.targetId);
}
catch {
// ignore
}
}
// TODO: Turn opts.details.exception into a JSHandle and retrieve the 'cause' property.
// If its an Error, recursively create a SymbolizedError.
let cause;
if (opts.resolvedCauseForTesting) {
cause = opts.resolvedCauseForTesting;
}
else if (opts.details.exception) {
try {
const causeRemoteObj = await SymbolizedError.#lookupCause(opts.devTools, opts.details.exception, opts.targetId);
if (causeRemoteObj) {
cause = await SymbolizedError.fromError({
devTools: opts.devTools,
error: causeRemoteObj,
targetId: opts.targetId,
});
}
}
catch {
// Ignore
}
}
return new SymbolizedError(message, stackTrace, cause);
}
static async fromError(opts) {
const details = await SymbolizedError.#getExceptionDetails(opts.devTools, opts.error, opts.targetId);
if (details) {
return SymbolizedError.fromDetails({
details,
devTools: opts.devTools,
targetId: opts.targetId,
includeStackAndCause: true,
});
}
return new SymbolizedError(SymbolizedError.#getMessageFromException(opts.error));
}
static #getMessage(details) {
// For Runtime.exceptionThrown with a present exception object, `details.text` will be "Uncaught" and
// we have to manually parse out the error text from the exception description.
// In the case of Runtime.getExceptionDetails, `details.text` has the Error.message.
if (details.text === 'Uncaught' && details.exception) {
return ('Uncaught ' +
SymbolizedError.#getMessageFromException(details.exception));
}
return details.text;
}
static #getMessageFromException(error) {
const messageWithRest = error.description?.split('\n at ', 2) ?? [];
return messageWithRest[0] ?? '';
}
static async #getExceptionDetails(devTools, error, targetId) {
if (!devTools || (error.type !== 'object' && error.subtype !== 'error')) {
return null;
}
const targetManager = devTools.universe.context.get(DevTools.TargetManager);
const target = targetId
? targetManager.targetById(targetId) || devTools.target
: devTools.target;
const model = target.model(DevTools.RuntimeModel);
return ((await model.getExceptionDetails(error.objectId)) ?? null);
}
static async #lookupCause(devTools, error, targetId) {
if (!devTools || (error.type !== 'object' && error.subtype !== 'error')) {
return null;
}
const targetManager = devTools.universe.context.get(DevTools.TargetManager);
const target = targetId
? targetManager.targetById(targetId) || devTools.target
: devTools.target;
const properties = await target.runtimeAgent().invoke_getProperties({
objectId: error.objectId,
});
if (properties.getError()) {
return null;
}
return properties.result.find(prop => prop.name === 'cause')?.value ?? null;
}
static createForTesting(message, stackTrace, cause) {
return new SymbolizedError(message, stackTrace, cause);
}
}
export async function createStackTraceForConsoleMessage(devTools, consoleMessage) {
const message = consoleMessage;
const rawStackTrace = message._rawStackTrace();
if (rawStackTrace) {
return createStackTrace(devTools, rawStackTrace, message._targetId());
}
return undefined;
}
export async function createStackTrace(devTools, rawStackTrace, targetId) {
const targetManager = devTools.universe.context.get(DevTools.TargetManager);
const target = targetId
? targetManager.targetById(targetId) || devTools.target
: devTools.target;
const model = target.model(DevTools.DebuggerModel);
// DevTools doesn't wait for source maps to attach before building a stack trace, rather it'll send
// an update event once a source map was attached and the stack trace retranslated. This doesn't
// work in the MCP case, so we'll collect all script IDs upfront and wait for any pending source map
// loads before creating the stack trace. We might also have to wait for Debugger.ScriptParsed events if
// the stack trace is created particularly early.
const scriptIds = new Set();
for (const frame of rawStackTrace.callFrames) {
scriptIds.add(frame.scriptId);
}
for (let asyncStack = rawStackTrace.parent; asyncStack; asyncStack = asyncStack.parent) {
for (const frame of asyncStack.callFrames) {
scriptIds.add(frame.scriptId);
}
}
const signal = AbortSignal.timeout(1_000);
await Promise.all([...scriptIds].map(id => waitForScript(model, id, signal)
.then(script => model.sourceMapManager().sourceMapForClientPromise(script))
.catch()));
const binding = devTools.universe.context.get(DevTools.DebuggerWorkspaceBinding);
// DevTools uses branded types for ScriptId and others. Casting the puppeteer protocol type to the DevTools protocol type is safe.
return binding.createStackTraceFromProtocolRuntime(rawStackTrace, target);
}
// Waits indefinitely for the script so pair it with Promise.race.
async function waitForScript(model, scriptId, signal) {
while (true) {
if (signal.aborted) {
throw signal.reason;
}
const script = model.scriptForId(scriptId);
if (script) {
return script;
}
await new Promise((resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
});
void model
.once('ParsedScriptSource')
.then(resolve);
});
}
}
+707
View File
@@ -0,0 +1,707 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import { UniverseManager } from './DevtoolsUtils.js';
import { McpPage } from './McpPage.js';
import { NetworkCollector, ConsoleCollector, } from './PageCollector.js';
import { Locator } from './third_party/index.js';
import { PredefinedNetworkConditions } from './third_party/index.js';
import { listPages } from './tools/pages.js';
import { CLOSE_PAGE_ERROR } from './tools/ToolDefinition.js';
import { ExtensionRegistry, } from './utils/ExtensionRegistry.js';
import { saveTemporaryFile } from './utils/files.js';
import { WaitForHelper } from './WaitForHelper.js';
const DEFAULT_TIMEOUT = 5_000;
const NAVIGATION_TIMEOUT = 10_000;
function getNetworkMultiplierFromString(condition) {
const puppeteerCondition = condition;
switch (puppeteerCondition) {
case 'Fast 4G':
return 1;
case 'Slow 4G':
return 2.5;
case 'Fast 3G':
return 5;
case 'Slow 3G':
return 10;
}
return 1;
}
export class McpContext {
browser;
logger;
// Maps LLM-provided isolatedContext name → Puppeteer BrowserContext.
#isolatedContexts = new Map();
// Auto-generated name counter for when no name is provided.
#nextIsolatedContextId = 1;
#pages = [];
#extensionServiceWorkers = [];
#mcpPages = new Map();
#selectedPage;
#networkCollector;
#consoleCollector;
#devtoolsUniverseManager;
#extensionRegistry = new ExtensionRegistry();
#isRunningTrace = false;
#screenRecorderData = null;
#inPageTools;
#nextPageId = 1;
#extensionPages = new WeakMap();
#extensionServiceWorkerMap = new WeakMap();
#nextExtensionServiceWorkerId = 1;
#nextSnapshotId = 1;
#traceResults = [];
#locatorClass;
#options;
constructor(browser, logger, options, locatorClass) {
this.browser = browser;
this.logger = logger;
this.#locatorClass = locatorClass;
this.#options = options;
this.#networkCollector = new NetworkCollector(this.browser);
this.#consoleCollector = new ConsoleCollector(this.browser, collect => {
return {
console: event => {
collect(event);
},
uncaughtError: event => {
collect(event);
},
issue: event => {
collect(event);
},
};
});
this.#devtoolsUniverseManager = new UniverseManager(this.browser);
}
async #init() {
const pages = await this.createPagesSnapshot();
await this.createExtensionServiceWorkersSnapshot();
await this.#networkCollector.init(pages);
await this.#consoleCollector.init(pages);
await this.#devtoolsUniverseManager.init(pages);
}
dispose() {
this.#networkCollector.dispose();
this.#consoleCollector.dispose();
this.#devtoolsUniverseManager.dispose();
for (const mcpPage of this.#mcpPages.values()) {
mcpPage.dispose();
}
this.#mcpPages.clear();
// Isolated contexts are intentionally not closed here.
// Either the entire browser will be closed or we disconnect
// without destroying browser state.
this.#isolatedContexts.clear();
}
static async from(browser, logger, opts,
/* Let tests use unbundled Locator class to avoid overly strict checks within puppeteer that fail when mixing bundled and unbundled class instances */
locatorClass = Locator) {
const context = new McpContext(browser, logger, opts, locatorClass);
await context.#init();
return context;
}
resolveCdpRequestId(page, cdpRequestId) {
if (!cdpRequestId) {
this.logger('no network request');
return;
}
const request = this.#networkCollector.find(page.pptrPage, request => {
// @ts-expect-error id is internal.
return request.id === cdpRequestId;
});
if (!request) {
this.logger('no network request for ' + cdpRequestId);
return;
}
return this.#networkCollector.getIdForResource(request);
}
resolveCdpElementId(page, cdpBackendNodeId) {
if (!cdpBackendNodeId) {
this.logger('no cdpBackendNodeId');
return;
}
const snapshot = page.textSnapshot;
if (!snapshot) {
this.logger('no text snapshot');
return;
}
// TODO: index by backendNodeId instead.
const queue = [snapshot.root];
while (queue.length) {
const current = queue.pop();
if (current.backendNodeId === cdpBackendNodeId) {
return current.id;
}
for (const child of current.children) {
queue.push(child);
}
}
return;
}
getNetworkRequests(page, includePreservedRequests) {
return this.#networkCollector.getData(page.pptrPage, includePreservedRequests);
}
getConsoleData(page, includePreservedMessages) {
return this.#consoleCollector.getData(page.pptrPage, includePreservedMessages);
}
getDevToolsUniverse(page) {
return this.#devtoolsUniverseManager.get(page.pptrPage);
}
getConsoleMessageStableId(message) {
return this.#consoleCollector.getIdForResource(message);
}
getConsoleMessageById(page, id) {
return this.#consoleCollector.getById(page.pptrPage, id);
}
async newPage(background, isolatedContextName) {
let page;
if (isolatedContextName !== undefined) {
let ctx = this.#isolatedContexts.get(isolatedContextName);
if (!ctx) {
ctx = await this.browser.createBrowserContext();
this.#isolatedContexts.set(isolatedContextName, ctx);
}
page = await ctx.newPage();
}
else {
page = await this.browser.newPage({ background });
}
await this.createPagesSnapshot();
this.selectPage(this.#getMcpPage(page));
this.#networkCollector.addPage(page);
this.#consoleCollector.addPage(page);
return this.#getMcpPage(page);
}
async closePage(pageId) {
if (this.#pages.length === 1) {
throw new Error(CLOSE_PAGE_ERROR);
}
const page = this.getPageById(pageId);
if (page) {
page.dispose();
this.#mcpPages.delete(page.pptrPage);
}
await page.pptrPage.close({ runBeforeUnload: false });
}
getNetworkRequestById(page, reqid) {
return this.#networkCollector.getById(page.pptrPage, reqid);
}
async restoreEmulation(page) {
const currentSetting = page.emulationSettings;
await this.emulate(currentSetting, page.pptrPage);
}
async emulate(options, targetPage) {
const page = targetPage ?? this.getSelectedPptrPage();
const mcpPage = this.#getMcpPage(page);
const newSettings = { ...mcpPage.emulationSettings };
if (!options.networkConditions) {
await page.emulateNetworkConditions(null);
delete newSettings.networkConditions;
}
else if (options.networkConditions === 'Offline') {
await page.emulateNetworkConditions({
offline: true,
download: 0,
upload: 0,
latency: 0,
});
newSettings.networkConditions = 'Offline';
}
else if (options.networkConditions in PredefinedNetworkConditions) {
const networkCondition = PredefinedNetworkConditions[options.networkConditions];
await page.emulateNetworkConditions(networkCondition);
newSettings.networkConditions = options.networkConditions;
}
if (!options.cpuThrottlingRate) {
await page.emulateCPUThrottling(1);
delete newSettings.cpuThrottlingRate;
}
else {
await page.emulateCPUThrottling(options.cpuThrottlingRate);
newSettings.cpuThrottlingRate = options.cpuThrottlingRate;
}
if (!options.geolocation) {
await page.setGeolocation({ latitude: 0, longitude: 0 });
delete newSettings.geolocation;
}
else {
await page.setGeolocation(options.geolocation);
newSettings.geolocation = options.geolocation;
}
if (!options.userAgent) {
await page.setUserAgent({ userAgent: undefined });
delete newSettings.userAgent;
}
else {
await page.setUserAgent({ userAgent: options.userAgent });
newSettings.userAgent = options.userAgent;
}
if (!options.colorScheme || options.colorScheme === 'auto') {
await page.emulateMediaFeatures([
{ name: 'prefers-color-scheme', value: '' },
]);
delete newSettings.colorScheme;
}
else {
await page.emulateMediaFeatures([
{ name: 'prefers-color-scheme', value: options.colorScheme },
]);
newSettings.colorScheme = options.colorScheme;
}
if (!options.viewport) {
await page.setViewport(null);
delete newSettings.viewport;
}
else {
const defaults = {
deviceScaleFactor: 1,
isMobile: false,
hasTouch: false,
isLandscape: false,
};
const viewport = { ...defaults, ...options.viewport };
await page.setViewport(viewport);
newSettings.viewport = viewport;
}
mcpPage.emulationSettings = Object.keys(newSettings).length
? newSettings
: {};
this.#updateSelectedPageTimeouts();
}
setIsRunningPerformanceTrace(x) {
this.#isRunningTrace = x;
}
isRunningPerformanceTrace() {
return this.#isRunningTrace;
}
getScreenRecorder() {
return this.#screenRecorderData;
}
setScreenRecorder(data) {
this.#screenRecorderData = data;
}
isCruxEnabled() {
return this.#options.performanceCrux;
}
getSelectedPptrPage() {
const page = this.#selectedPage;
if (!page) {
throw new Error('No page selected');
}
if (page.pptrPage.isClosed()) {
throw new Error(`The selected page has been closed. Call ${listPages().name} to see open pages.`);
}
return page.pptrPage;
}
getSelectedMcpPage() {
const page = this.getSelectedPptrPage();
return this.#getMcpPage(page);
}
getPageById(pageId) {
const page = this.#mcpPages.values().find(mcpPage => mcpPage.id === pageId);
if (!page) {
throw new Error('No page found');
}
return page;
}
getPageId(page) {
return this.#mcpPages.get(page)?.id;
}
#getMcpPage(page) {
const mcpPage = this.#mcpPages.get(page);
if (!mcpPage) {
throw new Error('No McpPage found for the given page.');
}
return mcpPage;
}
#getSelectedMcpPage() {
return this.#getMcpPage(this.getSelectedPptrPage());
}
isPageSelected(page) {
return this.#selectedPage?.pptrPage === page;
}
selectPage(newPage) {
this.#selectedPage = newPage;
this.#updateSelectedPageTimeouts();
}
setInPageTools(toolGroup) {
this.#inPageTools = toolGroup;
}
getInPageTools() {
return this.#inPageTools;
}
#updateSelectedPageTimeouts() {
const page = this.#getSelectedMcpPage();
// For waiters 5sec timeout should be sufficient.
// Increased in case we throttle the CPU
const cpuMultiplier = page.cpuThrottlingRate;
page.pptrPage.setDefaultTimeout(DEFAULT_TIMEOUT * cpuMultiplier);
// 10sec should be enough for the load event to be emitted during
// navigations.
// Increased in case we throttle the network requests
const networkMultiplier = getNetworkMultiplierFromString(page.networkConditions);
page.pptrPage.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT * networkMultiplier);
}
// Linear scan over per-page snapshots. The page count is small (typically
// 2-10) so a reverse index isn't worthwhile given the uid-reuse lifecycle
// complexity it would introduce.
getAXNodeByUid(uid) {
for (const mcpPage of this.#mcpPages.values()) {
const node = mcpPage.textSnapshot?.idToNode.get(uid);
if (node) {
return node;
}
}
return undefined;
}
/**
* Creates a snapshot of the extension service workers.
*/
async createExtensionServiceWorkersSnapshot() {
const allTargets = await this.browser.targets();
const serviceWorkers = allTargets.filter(target => {
return (target.type() === 'service_worker' &&
target.url().includes('chrome-extension://'));
});
for (const serviceWorker of serviceWorkers) {
if (!this.#extensionServiceWorkerMap.has(serviceWorker)) {
this.#extensionServiceWorkerMap.set(serviceWorker, 'sw-' + this.#nextExtensionServiceWorkerId++);
}
}
this.#extensionServiceWorkers = serviceWorkers.map(serviceWorker => {
return {
target: serviceWorker,
id: this.#extensionServiceWorkerMap.get(serviceWorker),
url: serviceWorker.url(),
};
});
return this.#extensionServiceWorkers;
}
async createPagesSnapshot() {
const { pages: allPages, isolatedContextNames } = await this.#getAllPages();
for (const page of allPages) {
let mcpPage = this.#mcpPages.get(page);
if (!mcpPage) {
mcpPage = new McpPage(page, this.#nextPageId++);
this.#mcpPages.set(page, mcpPage);
// We emulate a focused page for all pages to support multi-agent workflows.
void page.emulateFocusedPage(true).catch(error => {
this.logger('Error turning on focused page emulation', error);
});
}
mcpPage.isolatedContextName = isolatedContextNames.get(page);
}
// Prune orphaned #mcpPages entries (pages that no longer exist).
const currentPages = new Set(allPages);
for (const [page, mcpPage] of this.#mcpPages) {
if (!currentPages.has(page)) {
mcpPage.dispose();
this.#mcpPages.delete(page);
}
}
this.#pages = allPages.filter(page => {
return (this.#options.experimentalDevToolsDebugging ||
!page.url().startsWith('devtools://'));
});
if ((!this.#selectedPage ||
this.#pages.indexOf(this.#selectedPage.pptrPage) === -1) &&
this.#pages[0]) {
this.selectPage(this.#getMcpPage(this.#pages[0]));
}
await this.detectOpenDevToolsWindows();
return this.#pages;
}
async #getAllPages() {
const defaultCtx = this.browser.defaultBrowserContext();
const allPages = await this.browser.pages(this.#options.experimentalIncludeAllPages);
const allTargets = this.browser.targets();
const extensionTargets = allTargets.filter(target => {
return (target.url().startsWith('chrome-extension://') &&
target.type() === 'page');
});
for (const target of extensionTargets) {
// Right now target.page() returns null for popup and side panel pages.
let page = await target.page();
if (!page) {
// We need to cache pages instances for targets because target.asPage()
// returns a new page instance every time.
page = this.#extensionPages.get(target) ?? null;
if (!page) {
try {
page = await target.asPage();
this.#extensionPages.set(target, page);
}
catch (e) {
this.logger('Failed to get page for extension target', e);
}
}
}
if (page && !allPages.includes(page)) {
allPages.push(page);
}
}
// Build a reverse lookup from BrowserContext instance → name.
const contextToName = new Map();
for (const [name, ctx] of this.#isolatedContexts) {
contextToName.set(ctx, name);
}
// Auto-discover BrowserContexts not in our mapping (e.g., externally
// created incognito contexts) and assign generated names.
const knownContexts = new Set(this.#isolatedContexts.values());
for (const ctx of this.browser.browserContexts()) {
if (ctx !== defaultCtx && !ctx.closed && !knownContexts.has(ctx)) {
const name = `isolated-context-${this.#nextIsolatedContextId++}`;
this.#isolatedContexts.set(name, ctx);
contextToName.set(ctx, name);
}
}
// Map each page to its isolated context name (if any).
const isolatedContextNames = new Map();
for (const page of allPages) {
const ctx = page.browserContext();
const name = contextToName.get(ctx);
if (name) {
isolatedContextNames.set(page, name);
}
}
return { pages: allPages, isolatedContextNames };
}
async detectOpenDevToolsWindows() {
this.logger('Detecting open DevTools windows');
const { pages } = await this.#getAllPages();
await Promise.all(pages.map(async (page) => {
const mcpPage = this.#mcpPages.get(page);
if (!mcpPage) {
return;
}
// Prior to Chrome 144.0.7559.59, the command fails,
// Some Electron apps still use older version
// Fall back to not exposing DevTools at all.
try {
if (await page.hasDevTools()) {
mcpPage.devToolsPage = await page.openDevTools();
}
else {
mcpPage.devToolsPage = undefined;
}
}
catch {
mcpPage.devToolsPage = undefined;
}
}));
}
getExtensionServiceWorkers() {
return this.#extensionServiceWorkers;
}
getExtensionServiceWorkerId(extensionServiceWorker) {
return this.#extensionServiceWorkerMap.get(extensionServiceWorker.target);
}
getPages() {
return this.#pages;
}
getIsolatedContextName(page) {
return this.#mcpPages.get(page)?.isolatedContextName;
}
getDevToolsPage(page) {
return this.#mcpPages.get(page)?.devToolsPage;
}
async getDevToolsData(page) {
try {
this.logger('Getting DevTools UI data');
const devtoolsPage = this.getDevToolsPage(page.pptrPage);
if (!devtoolsPage) {
this.logger('No DevTools page detected');
return {};
}
const { cdpRequestId, cdpBackendNodeId } = await devtoolsPage.evaluate(async () => {
// @ts-expect-error no types
const UI = await import('/bundled/ui/legacy/legacy.js');
// @ts-expect-error no types
const SDK = await import('/bundled/core/sdk/sdk.js');
const request = UI.Context.Context.instance().flavor(SDK.NetworkRequest.NetworkRequest);
const node = UI.Context.Context.instance().flavor(SDK.DOMModel.DOMNode);
return {
cdpRequestId: request?.requestId(),
cdpBackendNodeId: node?.backendNodeId(),
};
});
return { cdpBackendNodeId, cdpRequestId };
}
catch (err) {
this.logger('error getting devtools data', err);
}
return {};
}
/**
* Creates a text snapshot of a page.
*/
async createTextSnapshot(page, verbose = false, devtoolsData = undefined) {
const rootNode = await page.pptrPage.accessibility.snapshot({
includeIframes: true,
interestingOnly: !verbose,
});
if (!rootNode) {
return;
}
const { uniqueBackendNodeIdToMcpId } = page;
const snapshotId = this.#nextSnapshotId++;
// Iterate through the whole accessibility node tree and assign node ids that
// will be used for the tree serialization and mapping ids back to nodes.
let idCounter = 0;
const idToNode = new Map();
const seenUniqueIds = new Set();
const assignIds = (node) => {
let id = '';
// @ts-expect-error untyped loaderId & backendNodeId.
const uniqueBackendId = `${node.loaderId}_${node.backendNodeId}`;
if (uniqueBackendNodeIdToMcpId.has(uniqueBackendId)) {
// Re-use MCP exposed ID if the uniqueId is the same.
id = uniqueBackendNodeIdToMcpId.get(uniqueBackendId);
}
else {
// Only generate a new ID if we have not seen the node before.
id = `${snapshotId}_${idCounter++}`;
uniqueBackendNodeIdToMcpId.set(uniqueBackendId, id);
}
seenUniqueIds.add(uniqueBackendId);
const nodeWithId = {
...node,
id,
children: node.children
? node.children.map(child => assignIds(child))
: [],
};
// The AXNode for an option doesn't contain its `value`.
// Therefore, set text content of the option as value.
if (node.role === 'option') {
const optionText = node.name;
if (optionText) {
nodeWithId.value = optionText.toString();
}
}
idToNode.set(nodeWithId.id, nodeWithId);
return nodeWithId;
};
const rootNodeWithId = assignIds(rootNode);
const snapshot = {
root: rootNodeWithId,
snapshotId: String(snapshotId),
idToNode,
hasSelectedElement: false,
verbose,
};
page.textSnapshot = snapshot;
const data = devtoolsData ?? (await this.getDevToolsData(page));
if (data?.cdpBackendNodeId) {
snapshot.hasSelectedElement = true;
snapshot.selectedElementUid = this.resolveCdpElementId(page, data?.cdpBackendNodeId);
}
// Clean up unique IDs that we did not see anymore.
for (const key of uniqueBackendNodeIdToMcpId.keys()) {
if (!seenUniqueIds.has(key)) {
uniqueBackendNodeIdToMcpId.delete(key);
}
}
}
async saveTemporaryFile(data, filename) {
return await saveTemporaryFile(data, filename);
}
async saveFile(data, filename) {
try {
const filePath = path.resolve(filename);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, data);
return { filename: filePath };
}
catch (err) {
this.logger(err);
throw new Error('Could not save a file', { cause: err });
}
}
storeTraceRecording(result) {
// Clear the trace results because we only consume the latest trace currently.
this.#traceResults = [];
this.#traceResults.push(result);
}
recordedTraces() {
return this.#traceResults;
}
getWaitForHelper(page, cpuMultiplier, networkMultiplier) {
return new WaitForHelper(page, cpuMultiplier, networkMultiplier);
}
waitForEventsAfterAction(action, options) {
const page = this.#getSelectedMcpPage();
const cpuMultiplier = page.cpuThrottlingRate;
const networkMultiplier = getNetworkMultiplierFromString(page.networkConditions);
const waitForHelper = this.getWaitForHelper(page.pptrPage, cpuMultiplier, networkMultiplier);
return waitForHelper.waitForEventsAfterAction(action, options);
}
getNetworkRequestStableId(request) {
return this.#networkCollector.getIdForResource(request);
}
waitForTextOnPage(text, timeout, targetPage) {
const page = targetPage ?? this.getSelectedPptrPage();
const frames = page.frames();
let locator = this.#locatorClass.race(frames.flatMap(frame => text.flatMap(value => [
frame.locator(`aria/${value}`),
frame.locator(`text/${value}`),
])));
if (timeout) {
locator = locator.setTimeout(timeout);
}
return locator.wait();
}
/**
* We need to ignore favicon request as they make our test flaky
*/
async setUpNetworkCollectorForTesting() {
this.#networkCollector = new NetworkCollector(this.browser, collect => {
return {
request: req => {
if (req.url().includes('favicon.ico')) {
return;
}
collect(req);
},
};
});
const { pages } = await this.#getAllPages();
await this.#networkCollector.init(pages);
}
async installExtension(extensionPath) {
const id = await this.browser.installExtension(extensionPath);
await this.#extensionRegistry.registerExtension(id, extensionPath);
return id;
}
async uninstallExtension(id) {
await this.browser.uninstallExtension(id);
this.#extensionRegistry.remove(id);
}
async triggerExtensionAction(id) {
const page = this.getSelectedPptrPage();
// @ts-expect-error internal puppeteer api is needed since we don't have a way to get
// a tab id at the moment
const theTarget = page._tabId;
const session = await this.browser.target().createCDPSession();
try {
await session.send('Extensions.triggerAction', {
id,
targetId: theTarget,
});
}
finally {
await session.detach();
}
}
listExtensions() {
return this.#extensionRegistry.list();
}
getExtension(id) {
return this.#extensionRegistry.getById(id);
}
}
+95
View File
@@ -0,0 +1,95 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { takeSnapshot } from './tools/snapshot.js';
/**
* Per-page state wrapper. Consolidates dialog, snapshot, emulation,
* and metadata that were previously scattered across Maps in McpContext.
*
* Internal class consumed only by McpContext. Fields are public for direct
* read/write access. The dialog field is private because it requires an
* event listener lifecycle managed by the constructor/dispose pair.
*/
export class McpPage {
pptrPage;
id;
// Snapshot
textSnapshot = null;
uniqueBackendNodeIdToMcpId = new Map();
// Emulation
emulationSettings = {};
// Metadata
isolatedContextName;
devToolsPage;
// Dialog
#dialog;
#dialogHandler;
constructor(page, id) {
this.pptrPage = page;
this.id = id;
this.#dialogHandler = (dialog) => {
this.#dialog = dialog;
};
page.on('dialog', this.#dialogHandler);
}
get dialog() {
return this.#dialog;
}
getDialog() {
return this.dialog;
}
clearDialog() {
this.#dialog = undefined;
}
get networkConditions() {
return this.emulationSettings.networkConditions ?? null;
}
get cpuThrottlingRate() {
return this.emulationSettings.cpuThrottlingRate ?? 1;
}
get geolocation() {
return this.emulationSettings.geolocation ?? null;
}
get viewport() {
return this.emulationSettings.viewport ?? null;
}
get userAgent() {
return this.emulationSettings.userAgent ?? null;
}
get colorScheme() {
return this.emulationSettings.colorScheme ?? null;
}
dispose() {
this.pptrPage.off('dialog', this.#dialogHandler);
}
async getElementByUid(uid) {
if (!this.textSnapshot) {
throw new Error(`No snapshot found for page ${this.id ?? '?'}. Use ${takeSnapshot.name} to capture one.`);
}
const node = this.textSnapshot.idToNode.get(uid);
if (!node) {
throw new Error(`Element uid "${uid}" not found on page ${this.id}.`);
}
return this.#resolveElementHandle(node, uid);
}
async #resolveElementHandle(node, uid) {
const message = `Element with uid ${uid} no longer exists on the page.`;
try {
const handle = await node.elementHandle();
if (!handle) {
throw new Error(message);
}
return handle;
}
catch (error) {
throw new Error(message, {
cause: error,
});
}
}
getAXNodeByUid(uid) {
return this.textSnapshot?.idToNode.get(uid);
}
}
+668
View File
@@ -0,0 +1,668 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { ConsoleFormatter } from './formatters/ConsoleFormatter.js';
import { IssueFormatter } from './formatters/IssueFormatter.js';
import { NetworkFormatter } from './formatters/NetworkFormatter.js';
import { SnapshotFormatter } from './formatters/SnapshotFormatter.js';
import { UncaughtError } from './PageCollector.js';
import { DevTools } from './third_party/index.js';
import { handleDialog } from './tools/pages.js';
import { getInsightOutput, getTraceSummary } from './trace-processing/parse.js';
import { paginate } from './utils/pagination.js';
async function getToolGroup(page) {
// Check if there is a `devtoolstooldiscovery` event listener
const windowHandle = await page.pptrPage.evaluateHandle(() => window);
// @ts-expect-error internal API
const client = page.pptrPage._client();
const { listeners } = await client.send('DOMDebugger.getEventListeners', {
objectId: windowHandle.remoteObject().objectId,
});
if (listeners.find(l => l.type === 'devtoolstooldiscovery') === undefined) {
return;
}
const toolGroup = await page.pptrPage.evaluate(() => {
return new Promise(resolve => {
const event = new CustomEvent('devtoolstooldiscovery');
// @ts-expect-error Adding custom property
event.respondWith = (toolGroup) => {
if (!window.__dtmcp) {
window.__dtmcp = {};
}
window.__dtmcp.toolGroup = toolGroup;
// When receiving a toolGroup for the first time, expose a simple execution helper
if (!window.__dtmcp.executeTool) {
window.__dtmcp.executeTool = async (toolName, args) => {
if (!window.__dtmcp?.toolGroup) {
throw new Error('No tools found on the page');
}
const tool = window.__dtmcp.toolGroup.tools.find(t => t.name === toolName);
if (!tool) {
throw new Error(`Tool ${toolName} not found`);
}
return await tool.execute(args);
};
}
resolve(toolGroup);
};
window.dispatchEvent(event);
// If the page does not synchronously call `event.respondWith`, return instead of timing out
setTimeout(() => {
resolve(undefined);
}, 0);
});
});
return toolGroup;
}
export class McpResponse {
#includePages = false;
#includeExtensionServiceWorkers = false;
#includeExtensionPages = false;
#snapshotParams;
#attachedNetworkRequestId;
#attachedNetworkRequestOptions;
#attachedConsoleMessageId;
#attachedTraceSummary;
#attachedTraceInsight;
#attachedLighthouseResult;
#textResponseLines = [];
#images = [];
#networkRequestsOptions;
#consoleDataOptions;
#listExtensions;
#listInPageTools;
#devToolsData;
#tabId;
#args;
#page;
constructor(args) {
this.#args = args;
}
setPage(page) {
this.#page = page;
}
attachDevToolsData(data) {
this.#devToolsData = data;
}
setTabId(tabId) {
this.#tabId = tabId;
}
setIncludePages(value) {
this.#includePages = value;
if (this.#args.categoryExtensions) {
this.#includeExtensionServiceWorkers = value;
this.#includeExtensionPages = value;
}
}
includeSnapshot(params) {
this.#snapshotParams = params ?? {
verbose: false,
};
}
setListExtensions() {
this.#listExtensions = true;
}
setListInPageTools() {
if (this.#args.categoryInPageTools) {
this.#listInPageTools = true;
}
}
setIncludeNetworkRequests(value, options) {
if (!value) {
this.#networkRequestsOptions = undefined;
return;
}
this.#networkRequestsOptions = {
include: value,
pagination: options?.pageSize || options?.pageIdx
? {
pageSize: options.pageSize,
pageIdx: options.pageIdx,
}
: undefined,
resourceTypes: options?.resourceTypes,
includePreservedRequests: options?.includePreservedRequests,
networkRequestIdInDevToolsUI: options?.networkRequestIdInDevToolsUI,
};
}
setIncludeConsoleData(value, options) {
if (!value) {
this.#consoleDataOptions = undefined;
return;
}
this.#consoleDataOptions = {
include: value,
pagination: options?.pageSize || options?.pageIdx
? {
pageSize: options.pageSize,
pageIdx: options.pageIdx,
}
: undefined,
types: options?.types,
includePreservedMessages: options?.includePreservedMessages,
};
}
attachNetworkRequest(reqId, options) {
this.#attachedNetworkRequestId = reqId;
this.#attachedNetworkRequestOptions = options;
}
attachConsoleMessage(msgid) {
this.#attachedConsoleMessageId = msgid;
}
attachTraceSummary(result) {
this.#attachedTraceSummary = result;
}
attachTraceInsight(trace, insightSetId, insightName) {
this.#attachedTraceInsight = {
trace,
insightSetId,
insightName,
};
}
attachLighthouseResult(result) {
this.#attachedLighthouseResult = result;
}
get includePages() {
return this.#includePages;
}
get attachedTraceSummary() {
return this.#attachedTraceSummary;
}
get attachedTracedInsight() {
return this.#attachedTraceInsight;
}
get attachedLighthouseResult() {
return this.#attachedLighthouseResult;
}
get includeNetworkRequests() {
return this.#networkRequestsOptions?.include ?? false;
}
get includeConsoleData() {
return this.#consoleDataOptions?.include ?? false;
}
get attachedNetworkRequestId() {
return this.#attachedNetworkRequestId;
}
get networkRequestsPageIdx() {
return this.#networkRequestsOptions?.pagination?.pageIdx;
}
get consoleMessagesPageIdx() {
return this.#consoleDataOptions?.pagination?.pageIdx;
}
get consoleMessagesTypes() {
return this.#consoleDataOptions?.types;
}
appendResponseLine(value) {
this.#textResponseLines.push(value);
}
attachImage(value) {
this.#images.push(value);
}
get responseLines() {
return this.#textResponseLines;
}
get images() {
return this.#images;
}
get snapshotParams() {
return this.#snapshotParams;
}
async handle(toolName, context) {
if (this.#includePages) {
await context.createPagesSnapshot();
}
if (this.#includeExtensionServiceWorkers) {
await context.createExtensionServiceWorkersSnapshot();
}
let snapshot;
if (this.#snapshotParams) {
if (!this.#page) {
throw new Error('Response must have a page');
}
await context.createTextSnapshot(this.#page, this.#snapshotParams.verbose, this.#devToolsData);
const textSnapshot = this.#page.textSnapshot;
if (textSnapshot) {
const formatter = new SnapshotFormatter(textSnapshot);
if (this.#snapshotParams.filePath) {
await context.saveFile(new TextEncoder().encode(formatter.toString()), this.#snapshotParams.filePath);
snapshot = this.#snapshotParams.filePath;
}
else {
snapshot = formatter;
}
}
}
let detailedNetworkRequest;
if (this.#attachedNetworkRequestId) {
if (!this.#page) {
throw new Error(`Response must have an McpPage`);
}
const request = context.getNetworkRequestById(this.#page, this.#attachedNetworkRequestId);
const formatter = await NetworkFormatter.from(request, {
requestId: this.#attachedNetworkRequestId,
requestIdResolver: req => context.getNetworkRequestStableId(req),
fetchData: true,
requestFilePath: this.#attachedNetworkRequestOptions?.requestFilePath,
responseFilePath: this.#attachedNetworkRequestOptions?.responseFilePath,
saveFile: (data, filename) => context.saveFile(data, filename),
});
detailedNetworkRequest = formatter;
}
let detailedConsoleMessage;
if (this.#attachedConsoleMessageId) {
if (!this.#page) {
throw new Error(`Response must have an McpPage`);
}
const message = context.getConsoleMessageById(this.#page, this.#attachedConsoleMessageId);
const consoleMessageStableId = this.#attachedConsoleMessageId;
if ('args' in message || message instanceof UncaughtError) {
const consoleMessage = message;
const devTools = context.getDevToolsUniverse(this.#page);
detailedConsoleMessage = await ConsoleFormatter.from(consoleMessage, {
id: consoleMessageStableId,
fetchDetailedData: true,
devTools: devTools ?? undefined,
});
}
else if (message instanceof DevTools.AggregatedIssue) {
const formatter = new IssueFormatter(message, {
id: consoleMessageStableId,
requestIdResolver: context.resolveCdpRequestId.bind(context, this.#page),
elementIdResolver: context.resolveCdpElementId.bind(context, this.#page),
});
if (!formatter.isValid()) {
throw new Error("Can't provide details for the msgid " + consoleMessageStableId);
}
detailedConsoleMessage = formatter;
}
}
let extensions;
if (this.#listExtensions) {
extensions = context.listExtensions();
}
let inPageTools;
if (this.#listInPageTools) {
inPageTools = await getToolGroup(context.getSelectedMcpPage());
context.setInPageTools(inPageTools);
}
let consoleMessages;
if (this.#consoleDataOptions?.include) {
if (!this.#page) {
throw new Error(`Response must have an McpPage`);
}
const page = this.#page;
let messages = context.getConsoleData(this.#page, this.#consoleDataOptions.includePreservedMessages);
if (this.#consoleDataOptions.types?.length) {
const normalizedTypes = new Set(this.#consoleDataOptions.types);
messages = messages.filter(message => {
if ('type' in message) {
return normalizedTypes.has(message.type());
}
if (message instanceof DevTools.AggregatedIssue) {
return normalizedTypes.has('issue');
}
return normalizedTypes.has('error');
});
}
consoleMessages = (await Promise.all(messages.map(async (item) => {
const consoleMessageStableId = context.getConsoleMessageStableId(item);
if ('args' in item || item instanceof UncaughtError) {
const consoleMessage = item;
const devTools = context.getDevToolsUniverse(page);
return await ConsoleFormatter.from(consoleMessage, {
id: consoleMessageStableId,
fetchDetailedData: false,
devTools: devTools ?? undefined,
});
}
if (item instanceof DevTools.AggregatedIssue) {
const formatter = new IssueFormatter(item, {
id: consoleMessageStableId,
});
if (!formatter.isValid()) {
return null;
}
return formatter;
}
return null;
}))).filter(item => item !== null);
}
let networkRequests;
if (this.#networkRequestsOptions?.include) {
if (!this.#page) {
throw new Error(`Response must have an McpPage`);
}
let requests = context.getNetworkRequests(this.#page, this.#networkRequestsOptions?.includePreservedRequests);
// Apply resource type filtering if specified
if (this.#networkRequestsOptions.resourceTypes?.length) {
const normalizedTypes = new Set(this.#networkRequestsOptions.resourceTypes);
requests = requests.filter(request => {
const type = request.resourceType();
return normalizedTypes.has(type);
});
}
if (requests.length) {
networkRequests = await Promise.all(requests.map(request => NetworkFormatter.from(request, {
requestId: context.getNetworkRequestStableId(request),
selectedInDevToolsUI: context.getNetworkRequestStableId(request) ===
this.#networkRequestsOptions?.networkRequestIdInDevToolsUI,
fetchData: false,
saveFile: (data, filename) => context.saveFile(data, filename),
})));
}
}
return this.format(toolName, context, {
detailedConsoleMessage,
consoleMessages,
snapshot,
detailedNetworkRequest,
networkRequests,
traceInsight: this.#attachedTraceInsight,
traceSummary: this.#attachedTraceSummary,
extensions,
lighthouseResult: this.#attachedLighthouseResult,
inPageTools,
});
}
format(toolName, context, data) {
const structuredContent = {};
const response = [];
if (this.#textResponseLines.length) {
structuredContent.message = this.#textResponseLines.join('\n');
response.push(...this.#textResponseLines);
}
const networkConditions = this.#page?.networkConditions;
if (networkConditions) {
const timeout = this.#page.pptrPage.getDefaultNavigationTimeout();
response.push(`Emulating network conditions: ${networkConditions}`);
response.push(`Default navigation timeout set to ${timeout} ms`);
structuredContent.networkConditions = networkConditions;
structuredContent.navigationTimeout = timeout;
}
const viewport = this.#page?.viewport;
if (viewport) {
response.push(`Emulating viewport: ${JSON.stringify(viewport)}`);
structuredContent.viewport = viewport;
}
const userAgent = this.#page?.userAgent;
if (userAgent) {
response.push(`Emulating user agent: ${userAgent}`);
structuredContent.userAgent = userAgent;
}
const cpuThrottlingRate = this.#page?.cpuThrottlingRate ?? 1;
if (cpuThrottlingRate > 1) {
response.push(`Emulating CPU throttling: ${cpuThrottlingRate}x slowdown`);
structuredContent.cpuThrottlingRate = cpuThrottlingRate;
}
const colorScheme = this.#page?.colorScheme;
if (colorScheme) {
response.push(`Emulating color scheme: ${colorScheme}`);
structuredContent.colorScheme = colorScheme;
}
const dialog = this.#page?.getDialog();
if (dialog) {
const defaultValueIfNeeded = dialog.type() === 'prompt'
? ` (default value: "${dialog.defaultValue()}")`
: '';
response.push(`# Open dialog
${dialog.type()}: ${dialog.message()}${defaultValueIfNeeded}.
Call ${handleDialog.name} to handle it before continuing.`);
structuredContent.dialog = {
type: dialog.type(),
message: dialog.message(),
defaultValue: dialog.defaultValue(),
};
}
if (this.#includePages) {
const allPages = context.getPages();
const { regularPages, extensionPages } = allPages.reduce((acc, page) => {
if (page.url().startsWith('chrome-extension://')) {
acc.extensionPages.push(page);
}
else {
acc.regularPages.push(page);
}
return acc;
}, { regularPages: [], extensionPages: [] });
if (regularPages.length) {
const parts = [`## Pages`];
const structuredPages = [];
for (const page of regularPages) {
const isolatedContextName = context.getIsolatedContextName(page);
const contextLabel = isolatedContextName
? ` isolatedContext=${isolatedContextName}`
: '';
parts.push(`${context.getPageId(page)}: ${page.url()}${context.isPageSelected(page) ? ' [selected]' : ''}${contextLabel}`);
structuredPages.push(createStructuredPage(page, context));
}
response.push(...parts);
structuredContent.pages = structuredPages;
}
if (this.#includeExtensionPages) {
if (extensionPages.length) {
response.push(`## Extension Pages`);
const structuredExtensionPages = [];
for (const page of extensionPages) {
const isolatedContextName = context.getIsolatedContextName(page);
const contextLabel = isolatedContextName
? ` isolatedContext=${isolatedContextName}`
: '';
response.push(`${context.getPageId(page)}: ${page.url()}${context.isPageSelected(page) ? ' [selected]' : ''}${contextLabel}`);
structuredExtensionPages.push(createStructuredPage(page, context));
}
structuredContent.extensionPages = structuredExtensionPages;
}
}
}
if (this.#includeExtensionServiceWorkers) {
if (context.getExtensionServiceWorkers().length) {
response.push(`## Extension Service Workers`);
}
for (const extensionServiceWorker of context.getExtensionServiceWorkers()) {
response.push(`${extensionServiceWorker.id}: ${extensionServiceWorker.url}`);
}
structuredContent.extensionServiceWorkers = context
.getExtensionServiceWorkers()
.map(extensionServiceWorker => {
return {
id: extensionServiceWorker.id,
url: extensionServiceWorker.url,
};
});
}
if (this.#tabId) {
structuredContent.tabId = this.#tabId;
}
if (data.traceSummary) {
const summary = getTraceSummary(data.traceSummary);
response.push(summary);
structuredContent.traceSummary = summary;
structuredContent.traceInsights = [];
for (const insightSet of data.traceSummary.insights?.values() ?? []) {
for (const [insightName, model] of Object.entries(insightSet.model)) {
structuredContent.traceInsights.push({
insightName,
insightKey: model.insightKey,
});
}
}
}
if (data.traceInsight) {
const insightOutput = getInsightOutput(data.traceInsight.trace, data.traceInsight.insightSetId, data.traceInsight.insightName);
if ('error' in insightOutput) {
response.push(insightOutput.error);
}
else {
response.push(insightOutput.output);
}
}
if (data.lighthouseResult) {
structuredContent.lighthouseResult = data.lighthouseResult;
const { summary, reports } = data.lighthouseResult;
response.push('## Lighthouse Audit Results');
response.push(`Mode: ${summary.mode}`);
response.push(`Device: ${summary.device}`);
response.push(`URL: ${summary.url}`);
response.push('### Category Scores');
for (const score of summary.scores) {
response.push(`- ${score.title}: ${(score.score ?? 0) * 100} (${score.id})`);
}
response.push('### Audit Summary');
response.push(`Passed: ${summary.audits.passed}`);
response.push(`Failed: ${summary.audits.failed}`);
response.push(`Total Timing: ${summary.timing.total}ms`);
response.push('### Reports');
for (const report of reports) {
response.push(`- ${report}`);
}
}
if (data.snapshot) {
if (typeof data.snapshot === 'string') {
response.push(`Saved snapshot to ${data.snapshot}.`);
structuredContent.snapshotFilePath = data.snapshot;
}
else {
response.push('## Latest page snapshot');
response.push(data.snapshot.toString());
structuredContent.snapshot = data.snapshot.toJSON();
}
}
if (data.detailedNetworkRequest) {
response.push(data.detailedNetworkRequest.toStringDetailed());
structuredContent.networkRequest =
data.detailedNetworkRequest.toJSONDetailed();
}
if (data.detailedConsoleMessage) {
response.push(data.detailedConsoleMessage.toStringDetailed());
structuredContent.consoleMessage =
data.detailedConsoleMessage.toJSONDetailed();
}
if (data.extensions) {
structuredContent.extensions = data.extensions;
response.push('## Extensions');
if (data.extensions.length === 0) {
response.push('No extensions installed.');
}
else {
const extensionsMessage = data.extensions
.map(extension => {
return `id=${extension.id} "${extension.name}" v${extension.version} ${extension.isEnabled ? 'Enabled' : 'Disabled'}`;
})
.join('\n');
response.push(extensionsMessage);
}
}
if (this.#listInPageTools) {
structuredContent.inPageTools = data.inPageTools ?? undefined;
response.push('## In-page tools');
if (!data.inPageTools || !data.inPageTools.tools) {
response.push('No in-page tools available.');
}
else {
const toolGroup = data.inPageTools;
response.push(`${toolGroup.name}: ${toolGroup.description}`);
response.push('Available tools:');
const toolDefinitionsMessage = toolGroup.tools
.map(tool => {
return `name="${tool.name}", description="${tool.description}", inputSchema=${JSON.stringify(tool.inputSchema)}`;
})
.join('\n');
response.push(toolDefinitionsMessage);
}
}
if (this.#networkRequestsOptions?.include && data.networkRequests) {
const requests = data.networkRequests;
response.push('## Network requests');
if (requests.length) {
const paginationData = this.#dataWithPagination(requests, this.#networkRequestsOptions.pagination);
structuredContent.pagination = paginationData.pagination;
response.push(...paginationData.info);
if (data.networkRequests) {
structuredContent.networkRequests = [];
for (const formatter of paginationData.items) {
response.push(formatter.toString());
structuredContent.networkRequests.push(formatter.toJSON());
}
}
}
else {
response.push('No requests found.');
}
}
if (this.#consoleDataOptions?.include) {
const messages = data.consoleMessages ?? [];
response.push('## Console messages');
if (messages.length) {
const paginationData = this.#dataWithPagination(messages, this.#consoleDataOptions.pagination);
structuredContent.pagination = paginationData.pagination;
response.push(...paginationData.info);
response.push(...paginationData.items.map(message => message.toString()));
structuredContent.consoleMessages = paginationData.items.map(message => message.toJSON());
}
else {
response.push('<no console messages found>');
}
}
const text = {
type: 'text',
text: response.join('\n'),
};
const images = this.#images.map(imageData => {
return {
type: 'image',
...imageData,
};
});
return {
content: [text, ...images],
structuredContent,
};
}
#dataWithPagination(data, pagination) {
const response = [];
const paginationResult = paginate(data, pagination);
if (paginationResult.invalidPage) {
response.push('Invalid page number provided. Showing first page.');
}
const { startIndex, endIndex, currentPage, totalPages } = paginationResult;
response.push(`Showing ${startIndex + 1}-${endIndex} of ${data.length} (Page ${currentPage + 1} of ${totalPages}).`);
if (pagination) {
if (paginationResult.hasNextPage) {
response.push(`Next page: ${currentPage + 1}`);
}
if (paginationResult.hasPreviousPage) {
response.push(`Previous page: ${currentPage - 1}`);
}
}
return {
info: response,
items: paginationResult.items,
pagination: {
currentPage: paginationResult.currentPage,
totalPages: paginationResult.totalPages,
hasNextPage: paginationResult.hasNextPage,
hasPreviousPage: paginationResult.hasPreviousPage,
startIndex: paginationResult.startIndex,
endIndex: paginationResult.endIndex,
invalidPage: paginationResult.invalidPage,
},
};
}
resetResponseLineForTesting() {
this.#textResponseLines = [];
}
}
function createStructuredPage(page, context) {
const isolatedContextName = context.getIsolatedContextName(page);
const entry = {
id: context.getPageId(page),
url: page.url(),
selected: context.isPageSelected(page),
};
if (isolatedContextName) {
entry.isolatedContext = isolatedContextName;
}
return entry;
}
+37
View File
@@ -0,0 +1,37 @@
/**
* @license
* Copyright 2025 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
export class Mutex {
static Guard = class Guard {
#mutex;
constructor(mutex) {
this.#mutex = mutex;
}
dispose() {
return this.#mutex.release();
}
};
#locked = false;
#acquirers = [];
// This is FIFO.
async acquire() {
if (!this.#locked) {
this.#locked = true;
return new Mutex.Guard(this);
}
const { resolve, promise } = Promise.withResolvers();
this.#acquirers.push(resolve);
await promise;
return new Mutex.Guard(this);
}
release() {
const resolve = this.#acquirers.shift();
if (!resolve) {
this.#locked = false;
return;
}
resolve();
}
}
+310
View File
@@ -0,0 +1,310 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { FakeIssuesManager } from './DevtoolsUtils.js';
import { logger } from './logger.js';
import { DevTools } from './third_party/index.js';
export class UncaughtError {
details;
targetId;
constructor(details, targetId) {
this.details = details;
this.targetId = targetId;
}
}
function createIdGenerator() {
let i = 1;
return () => {
if (i === Number.MAX_SAFE_INTEGER) {
i = 0;
}
return i++;
};
}
export const stableIdSymbol = Symbol('stableIdSymbol');
export class PageCollector {
#browser;
#listenersInitializer;
#listeners = new WeakMap();
maxNavigationSaved = 3;
/**
* This maps a Page to a list of navigations with a sub-list
* of all collected resources.
* The newer navigations come first.
*/
storage = new WeakMap();
constructor(browser, listeners) {
this.#browser = browser;
this.#listenersInitializer = listeners;
}
async init(pages) {
for (const page of pages) {
this.addPage(page);
}
this.#browser.on('targetcreated', this.#onTargetCreated);
this.#browser.on('targetdestroyed', this.#onTargetDestroyed);
}
dispose() {
this.#browser.off('targetcreated', this.#onTargetCreated);
this.#browser.off('targetdestroyed', this.#onTargetDestroyed);
}
#onTargetCreated = async (target) => {
try {
const page = await target.page();
if (!page) {
return;
}
this.addPage(page);
}
catch (err) {
logger('Error getting a page for a target onTargetCreated', err);
}
};
#onTargetDestroyed = async (target) => {
try {
const page = await target.page();
if (!page) {
return;
}
this.cleanupPageDestroyed(page);
}
catch (err) {
logger('Error getting a page for a target onTargetDestroyed', err);
}
};
addPage(page) {
this.#initializePage(page);
}
#initializePage(page) {
if (this.storage.has(page)) {
return;
}
const idGenerator = createIdGenerator();
const storedLists = [[]];
this.storage.set(page, storedLists);
const listeners = this.#listenersInitializer(value => {
const withId = value;
withId[stableIdSymbol] = idGenerator();
const navigations = this.storage.get(page) ?? [[]];
navigations[0].push(withId);
});
listeners['framenavigated'] = (frame) => {
// Only split the storage on main frame navigation
if (frame !== page.mainFrame()) {
return;
}
this.splitAfterNavigation(page);
};
for (const [name, listener] of Object.entries(listeners)) {
page.on(name, listener);
}
this.#listeners.set(page, listeners);
}
splitAfterNavigation(page) {
const navigations = this.storage.get(page);
if (!navigations) {
return;
}
// Add the latest navigation first
navigations.unshift([]);
navigations.splice(this.maxNavigationSaved);
}
cleanupPageDestroyed(page) {
const listeners = this.#listeners.get(page);
if (listeners) {
for (const [name, listener] of Object.entries(listeners)) {
page.off(name, listener);
}
}
this.storage.delete(page);
}
getData(page, includePreservedData) {
const navigations = this.storage.get(page);
if (!navigations) {
return [];
}
if (!includePreservedData) {
return navigations[0];
}
const data = [];
for (let index = this.maxNavigationSaved; index >= 0; index--) {
if (navigations[index]) {
data.push(...navigations[index]);
}
}
return data;
}
getIdForResource(resource) {
return resource[stableIdSymbol] ?? -1;
}
getById(page, stableId) {
const navigations = this.storage.get(page);
if (!navigations) {
throw new Error('No requests found for selected page');
}
const item = this.find(page, item => item[stableIdSymbol] === stableId);
if (item) {
return item;
}
throw new Error('Request not found for selected page');
}
find(page, filter) {
const navigations = this.storage.get(page);
if (!navigations) {
return;
}
for (const navigation of navigations) {
const item = navigation.find(filter);
if (item) {
return item;
}
}
return;
}
}
export class ConsoleCollector extends PageCollector {
#subscribedPages = new WeakMap();
addPage(page) {
super.addPage(page);
if (!this.#subscribedPages.has(page)) {
const subscriber = new PageEventSubscriber(page);
this.#subscribedPages.set(page, subscriber);
void subscriber.subscribe();
}
}
cleanupPageDestroyed(page) {
super.cleanupPageDestroyed(page);
this.#subscribedPages.get(page)?.unsubscribe();
this.#subscribedPages.delete(page);
}
}
class PageEventSubscriber {
#issueManager = new FakeIssuesManager();
#issueAggregator = new DevTools.IssueAggregator(this.#issueManager);
#seenKeys = new Set();
#seenIssues = new Set();
#page;
#session;
#targetId;
constructor(page) {
this.#page = page;
// @ts-expect-error use existing CDP client (internal Puppeteer API).
this.#session = this.#page._client();
// @ts-expect-error use internal Puppeteer API to get target ID
this.#targetId = this.#session.target()._targetId;
}
#resetIssueAggregator() {
this.#issueManager = new FakeIssuesManager();
if (this.#issueAggregator) {
this.#issueAggregator.removeEventListener("AggregatedIssueUpdated" /* DevTools.IssueAggregatorEvents.AGGREGATED_ISSUE_UPDATED */, this.#onAggregatedIssue);
}
this.#issueAggregator = new DevTools.IssueAggregator(this.#issueManager);
this.#issueAggregator.addEventListener("AggregatedIssueUpdated" /* DevTools.IssueAggregatorEvents.AGGREGATED_ISSUE_UPDATED */, this.#onAggregatedIssue);
}
async subscribe() {
this.#resetIssueAggregator();
this.#page.on('framenavigated', this.#onFrameNavigated);
this.#session.on('Audits.issueAdded', this.#onIssueAdded);
this.#session.on('Runtime.exceptionThrown', this.#onExceptionThrown);
try {
await this.#session.send('Audits.enable');
}
catch (error) {
logger('Error subscribing to issues', error);
}
}
unsubscribe() {
this.#seenKeys.clear();
this.#seenIssues.clear();
this.#page.off('framenavigated', this.#onFrameNavigated);
this.#session.off('Audits.issueAdded', this.#onIssueAdded);
this.#session.off('Runtime.exceptionThrown', this.#onExceptionThrown);
if (this.#issueAggregator) {
this.#issueAggregator.removeEventListener("AggregatedIssueUpdated" /* DevTools.IssueAggregatorEvents.AGGREGATED_ISSUE_UPDATED */, this.#onAggregatedIssue);
}
void this.#session.send('Audits.disable').catch(() => {
// might fail.
});
}
#onAggregatedIssue = (event) => {
if (this.#seenIssues.has(event.data)) {
return;
}
this.#seenIssues.add(event.data);
this.#page.emit('issue', event.data);
};
#onExceptionThrown = (event) => {
this.#page.emit('uncaughtError', new UncaughtError(event.exceptionDetails, this.#targetId));
};
// On navigation, we reset issue aggregation.
#onFrameNavigated = (frame) => {
// Only split the storage on main frame navigation
if (frame !== frame.page().mainFrame()) {
return;
}
this.#seenKeys.clear();
this.#seenIssues.clear();
this.#resetIssueAggregator();
};
#onIssueAdded = (data) => {
try {
const inspectorIssue = data.issue;
const issue = DevTools.createIssuesFromProtocolIssue(null,
// @ts-expect-error Protocol types diverge.
inspectorIssue)[0];
if (!issue) {
logger('No issue mapping for for the issue: ', inspectorIssue.code);
return;
}
const primaryKey = issue.primaryKey();
if (this.#seenKeys.has(primaryKey)) {
return;
}
this.#seenKeys.add(primaryKey);
this.#issueManager.dispatchEventToListeners("IssueAdded" /* DevTools.IssuesManagerEvents.ISSUE_ADDED */, {
issue,
// @ts-expect-error We don't care that issues model is null
issuesModel: null,
});
}
catch (error) {
logger('Error creating a new issue', error);
}
};
}
export class NetworkCollector extends PageCollector {
constructor(browser, listeners = collect => {
return {
request: req => {
collect(req);
},
};
}) {
super(browser, listeners);
}
splitAfterNavigation(page) {
const navigations = this.storage.get(page) ?? [];
if (!navigations) {
return;
}
const requests = navigations[0];
const lastRequestIdx = requests.findLastIndex(request => {
return request.frame() === page.mainFrame()
? request.isNavigationRequest()
: false;
});
// Keep all requests since the last navigation request including that
// navigation request itself.
// Keep the reference
if (lastRequestIdx !== -1) {
const fromCurrentNavigation = requests.splice(lastRequestIdx);
navigations.unshift(fromCurrentNavigation);
}
else {
navigations.unshift([]);
}
navigations.splice(this.maxNavigationSaved);
}
}
+18
View File
@@ -0,0 +1,18 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { McpResponse } from './McpResponse.js';
export class SlimMcpResponse extends McpResponse {
async handle(_toolName, _context) {
const text = {
type: 'text',
text: this.responseLines.join('\n'),
};
return {
content: [text],
structuredContent: text,
};
}
}
+139
View File
@@ -0,0 +1,139 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { logger } from './logger.js';
export class WaitForHelper {
#abortController = new AbortController();
#page;
#stableDomTimeout;
#stableDomFor;
#expectNavigationIn;
#navigationTimeout;
constructor(page, cpuTimeoutMultiplier, networkTimeoutMultiplier) {
this.#stableDomTimeout = 3000 * cpuTimeoutMultiplier;
this.#stableDomFor = 100 * cpuTimeoutMultiplier;
this.#expectNavigationIn = 100 * cpuTimeoutMultiplier;
this.#navigationTimeout = 3000 * networkTimeoutMultiplier;
this.#page = page;
}
/**
* A wrapper that executes a action and waits for
* a potential navigation, after which it waits
* for the DOM to be stable before returning.
*/
async waitForStableDom() {
const stableDomObserver = await this.#page.evaluateHandle(timeout => {
let timeoutId;
function callback() {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
domObserver.resolver.resolve();
domObserver.observer.disconnect();
}, timeout);
}
const domObserver = {
resolver: Promise.withResolvers(),
observer: new MutationObserver(callback),
};
// It's possible that the DOM is not gonna change so we
// need to start the timeout initially.
callback();
domObserver.observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
});
return domObserver;
}, this.#stableDomFor);
this.#abortController.signal.addEventListener('abort', async () => {
try {
await stableDomObserver.evaluate(observer => {
observer.observer.disconnect();
observer.resolver.resolve();
});
await stableDomObserver.dispose();
}
catch {
// Ignored cleanup errors
}
});
return Promise.race([
stableDomObserver.evaluate(async (observer) => {
return await observer.resolver.promise;
}),
this.timeout(this.#stableDomTimeout).then(() => {
throw new Error('Timeout');
}),
]);
}
async waitForNavigationStarted() {
// Currently Puppeteer does not have API
// For when a navigation is about to start
const navigationStartedPromise = new Promise(resolve => {
const listener = (event) => {
if ([
'historySameDocument',
'historyDifferentDocument',
'sameDocument',
].includes(event.navigationType)) {
resolve(false);
return;
}
resolve(true);
};
this.#page._client().on('Page.frameStartedNavigating', listener);
this.#abortController.signal.addEventListener('abort', () => {
resolve(false);
this.#page._client().off('Page.frameStartedNavigating', listener);
});
});
return await Promise.race([
navigationStartedPromise,
this.timeout(this.#expectNavigationIn).then(() => false),
]);
}
timeout(time) {
return new Promise(res => {
const id = setTimeout(res, time);
this.#abortController.signal.addEventListener('abort', () => {
res();
clearTimeout(id);
});
});
}
async waitForEventsAfterAction(action, options) {
const navigationFinished = this.waitForNavigationStarted()
.then(navigationStated => {
if (navigationStated) {
return this.#page.waitForNavigation({
timeout: options?.timeout ?? this.#navigationTimeout,
signal: this.#abortController.signal,
});
}
return;
})
.catch(error => logger(error));
try {
await action();
}
catch (error) {
// Clear up pending promises
this.#abortController.abort();
throw error;
}
try {
await navigationFinished;
// Wait for stable dom after navigation so we execute in
// the correct context
await this.waitForStableDom();
}
catch (error) {
logger(error);
}
finally {
this.#abortController.abort();
}
}
}
@@ -0,0 +1,651 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export const commands = {
click: {
description: 'Clicks on the provided element',
category: 'Input automation',
args: {
uid: {
name: 'uid',
type: 'string',
description: 'The uid of an element on the page from the page content snapshot',
required: true,
},
dblClick: {
name: 'dblClick',
type: 'boolean',
description: 'Set to true for double clicks. Default is false.',
required: false,
},
includeSnapshot: {
name: 'includeSnapshot',
type: 'boolean',
description: 'Whether to include a snapshot in the response. Default is false.',
required: false,
},
},
},
close_page: {
description: 'Closes the page by its index. The last open page cannot be closed.',
category: 'Navigation automation',
args: {
pageId: {
name: 'pageId',
type: 'number',
description: 'The ID of the page to close. Call list_pages to list pages.',
required: true,
},
},
},
drag: {
description: 'Drag an element onto another element',
category: 'Input automation',
args: {
from_uid: {
name: 'from_uid',
type: 'string',
description: 'The uid of the element to drag',
required: true,
},
to_uid: {
name: 'to_uid',
type: 'string',
description: 'The uid of the element to drop into',
required: true,
},
includeSnapshot: {
name: 'includeSnapshot',
type: 'boolean',
description: 'Whether to include a snapshot in the response. Default is false.',
required: false,
},
},
},
emulate: {
description: 'Emulates various features on the selected page.',
category: 'Emulation',
args: {
networkConditions: {
name: 'networkConditions',
type: 'string',
description: 'Throttle network. Omit to disable throttling.',
required: false,
enum: ['Offline', 'Slow 3G', 'Fast 3G', 'Slow 4G', 'Fast 4G'],
},
cpuThrottlingRate: {
name: 'cpuThrottlingRate',
type: 'number',
description: 'Represents the CPU slowdown factor. Omit or set the rate to 1 to disable throttling',
required: false,
},
geolocation: {
name: 'geolocation',
type: 'string',
description: 'Geolocation (`<latitude>x<longitude>`) to emulate. Latitude between -90 and 90. Longitude between -180 and 180. Omit clear the geolocation override.',
required: false,
},
userAgent: {
name: 'userAgent',
type: 'string',
description: 'User agent to emulate. Set to empty string to clear the user agent override.',
required: false,
},
colorScheme: {
name: 'colorScheme',
type: 'string',
description: 'Emulate the dark or the light mode. Set to "auto" to reset to the default.',
required: false,
enum: ['dark', 'light', 'auto'],
},
viewport: {
name: 'viewport',
type: 'string',
description: "Emulate device viewports '<width>x<height>x<devicePixelRatio>[,mobile][,touch][,landscape]'. 'touch' and 'mobile' to emulate mobile devices. 'landscape' to emulate landscape mode.",
required: false,
},
},
},
evaluate_script: {
description: 'Evaluate a JavaScript function inside the currently selected page. Returns the response as JSON,\nso returned values have to be JSON-serializable.',
category: 'Debugging',
args: {
function: {
name: 'function',
type: 'string',
description: 'A JavaScript function declaration to be executed by the tool in the currently selected page.\nExample without arguments: `() => {\n return document.title\n}` or `async () => {\n return await fetch("example.com")\n}`.\nExample with arguments: `(el) => {\n return el.innerText;\n}`\n',
required: true,
},
args: {
name: 'args',
type: 'array',
description: 'An optional list of arguments to pass to the function.',
required: false,
},
},
},
fill: {
description: 'Type text into a input, text area or select an option from a <select> element.',
category: 'Input automation',
args: {
uid: {
name: 'uid',
type: 'string',
description: 'The uid of an element on the page from the page content snapshot',
required: true,
},
value: {
name: 'value',
type: 'string',
description: 'The value to fill in',
required: true,
},
includeSnapshot: {
name: 'includeSnapshot',
type: 'boolean',
description: 'Whether to include a snapshot in the response. Default is false.',
required: false,
},
},
},
fill_form: {
description: 'Fill out multiple form elements at once',
category: 'Input automation',
args: {
elements: {
name: 'elements',
type: 'array',
description: 'Elements from snapshot to fill out.',
required: true,
},
includeSnapshot: {
name: 'includeSnapshot',
type: 'boolean',
description: 'Whether to include a snapshot in the response. Default is false.',
required: false,
},
},
},
get_console_message: {
description: 'Gets a console message by its ID. You can get all messages by calling list_console_messages.',
category: 'Debugging',
args: {
msgid: {
name: 'msgid',
type: 'number',
description: 'The msgid of a console message on the page from the listed console messages',
required: true,
},
},
},
get_network_request: {
description: 'Gets a network request by an optional reqid, if omitted returns the currently selected request in the DevTools Network panel.',
category: 'Network',
args: {
reqid: {
name: 'reqid',
type: 'number',
description: 'The reqid of the network request. If omitted returns the currently selected request in the DevTools Network panel.',
required: false,
},
requestFilePath: {
name: 'requestFilePath',
type: 'string',
description: 'The absolute or relative path to save the request body to. If omitted, the body is returned inline.',
required: false,
},
responseFilePath: {
name: 'responseFilePath',
type: 'string',
description: 'The absolute or relative path to save the response body to. If omitted, the body is returned inline.',
required: false,
},
},
},
handle_dialog: {
description: 'If a browser dialog was opened, use this command to handle it',
category: 'Input automation',
args: {
action: {
name: 'action',
type: 'string',
description: 'Whether to dismiss or accept the dialog',
required: true,
enum: ['accept', 'dismiss'],
},
promptText: {
name: 'promptText',
type: 'string',
description: 'Optional prompt text to enter into the dialog.',
required: false,
},
},
},
hover: {
description: 'Hover over the provided element',
category: 'Input automation',
args: {
uid: {
name: 'uid',
type: 'string',
description: 'The uid of an element on the page from the page content snapshot',
required: true,
},
includeSnapshot: {
name: 'includeSnapshot',
type: 'boolean',
description: 'Whether to include a snapshot in the response. Default is false.',
required: false,
},
},
},
lighthouse_audit: {
description: 'Get Lighthouse score and reports for accessibility, SEO and best practices. This excludes performance. For performance audits, run performance_start_trace',
category: 'Debugging',
args: {
mode: {
name: 'mode',
type: 'string',
description: '"navigation" reloads & audits. "snapshot" analyzes current state.',
required: false,
default: 'navigation',
enum: ['navigation', 'snapshot'],
},
device: {
name: 'device',
type: 'string',
description: 'Device to emulate.',
required: false,
default: 'desktop',
enum: ['desktop', 'mobile'],
},
outputDirPath: {
name: 'outputDirPath',
type: 'string',
description: 'Directory for reports. If omitted, uses temporary files.',
required: false,
},
},
},
list_console_messages: {
description: 'List all console messages for the currently selected page since the last navigation.',
category: 'Debugging',
args: {
pageSize: {
name: 'pageSize',
type: 'integer',
description: 'Maximum number of messages to return. When omitted, returns all requests.',
required: false,
},
pageIdx: {
name: 'pageIdx',
type: 'integer',
description: 'Page number to return (0-based). When omitted, returns the first page.',
required: false,
},
types: {
name: 'types',
type: 'array',
description: 'Filter messages to only return messages of the specified resource types. When omitted or empty, returns all messages.',
required: false,
},
includePreservedMessages: {
name: 'includePreservedMessages',
type: 'boolean',
description: 'Set to true to return the preserved messages over the last 3 navigations.',
required: false,
default: false,
},
},
},
list_network_requests: {
description: 'List all requests for the currently selected page since the last navigation.',
category: 'Network',
args: {
pageSize: {
name: 'pageSize',
type: 'integer',
description: 'Maximum number of requests to return. When omitted, returns all requests.',
required: false,
},
pageIdx: {
name: 'pageIdx',
type: 'integer',
description: 'Page number to return (0-based). When omitted, returns the first page.',
required: false,
},
resourceTypes: {
name: 'resourceTypes',
type: 'array',
description: 'Filter requests to only return requests of the specified resource types. When omitted or empty, returns all requests.',
required: false,
},
includePreservedRequests: {
name: 'includePreservedRequests',
type: 'boolean',
description: 'Set to true to return the preserved requests over the last 3 navigations.',
required: false,
default: false,
},
},
},
list_pages: {
description: 'Get a list of pages open in the browser.',
category: 'Navigation automation',
args: {},
},
navigate_page: {
description: 'Go to a URL, or back, forward, or reload. Use project URL if not specified otherwise.',
category: 'Navigation automation',
args: {
type: {
name: 'type',
type: 'string',
description: 'Navigate the page by URL, back or forward in history, or reload.',
required: false,
enum: ['url', 'back', 'forward', 'reload'],
},
url: {
name: 'url',
type: 'string',
description: 'Target URL (only type=url)',
required: false,
},
ignoreCache: {
name: 'ignoreCache',
type: 'boolean',
description: 'Whether to ignore cache on reload.',
required: false,
},
handleBeforeUnload: {
name: 'handleBeforeUnload',
type: 'string',
description: 'Whether to auto accept or beforeunload dialogs triggered by this navigation. Default is accept.',
required: false,
enum: ['accept', 'decline'],
},
initScript: {
name: 'initScript',
type: 'string',
description: 'A JavaScript script to be executed on each new document before any other scripts for the next navigation.',
required: false,
},
timeout: {
name: 'timeout',
type: 'integer',
description: 'Maximum wait time in milliseconds. If set to 0, the default timeout will be used.',
required: false,
},
},
},
new_page: {
description: 'Open a new tab and load a URL. Use project URL if not specified otherwise.',
category: 'Navigation automation',
args: {
url: {
name: 'url',
type: 'string',
description: 'URL to load in a new page.',
required: true,
},
background: {
name: 'background',
type: 'boolean',
description: 'Whether to open the page in the background without bringing it to the front. Default is false (foreground).',
required: false,
},
isolatedContext: {
name: 'isolatedContext',
type: 'string',
description: 'If specified, the page is created in an isolated browser context with the given name. Pages in the same browser context share cookies and storage. Pages in different browser contexts are fully isolated.',
required: false,
},
timeout: {
name: 'timeout',
type: 'integer',
description: 'Maximum wait time in milliseconds. If set to 0, the default timeout will be used.',
required: false,
},
},
},
performance_analyze_insight: {
description: 'Provides more detailed information on a specific Performance Insight of an insight set that was highlighted in the results of a trace recording.',
category: 'Performance',
args: {
insightSetId: {
name: 'insightSetId',
type: 'string',
description: 'The id for the specific insight set. Only use the ids given in the "Available insight sets" list.',
required: true,
},
insightName: {
name: 'insightName',
type: 'string',
description: 'The name of the Insight you want more information on. For example: "DocumentLatency" or "LCPBreakdown"',
required: true,
},
},
},
performance_start_trace: {
description: 'Start a performance trace on the selected webpage. Use to find frontend performance issues, Core Web Vitals (LCP, INP, CLS), and improve page load speed.',
category: 'Performance',
args: {
reload: {
name: 'reload',
type: 'boolean',
description: 'Determines if, once tracing has started, the current selected page should be automatically reloaded. Navigate the page to the right URL using the navigate_page tool BEFORE starting the trace if reload or autoStop is set to true.',
required: false,
default: true,
},
autoStop: {
name: 'autoStop',
type: 'boolean',
description: 'Determines if the trace recording should be automatically stopped.',
required: false,
default: true,
},
filePath: {
name: 'filePath',
type: 'string',
description: 'The absolute file path, or a file path relative to the current working directory, to save the raw trace data. For example, trace.json.gz (compressed) or trace.json (uncompressed).',
required: false,
},
},
},
performance_stop_trace: {
description: 'Stop the active performance trace recording on the selected webpage.',
category: 'Performance',
args: {
filePath: {
name: 'filePath',
type: 'string',
description: 'The absolute file path, or a file path relative to the current working directory, to save the raw trace data. For example, trace.json.gz (compressed) or trace.json (uncompressed).',
required: false,
},
},
},
press_key: {
description: 'Press a key or key combination. Use this when other input methods like fill() cannot be used (e.g., keyboard shortcuts, navigation keys, or special key combinations).',
category: 'Input automation',
args: {
key: {
name: 'key',
type: 'string',
description: 'A key or a combination (e.g., "Enter", "Control+A", "Control++", "Control+Shift+R"). Modifiers: Control, Shift, Alt, Meta',
required: true,
},
includeSnapshot: {
name: 'includeSnapshot',
type: 'boolean',
description: 'Whether to include a snapshot in the response. Default is false.',
required: false,
},
},
},
resize_page: {
description: "Resizes the selected page's window so that the page has specified dimension",
category: 'Emulation',
args: {
width: {
name: 'width',
type: 'number',
description: 'Page width',
required: true,
},
height: {
name: 'height',
type: 'number',
description: 'Page height',
required: true,
},
},
},
select_page: {
description: 'Select a page as a context for future tool calls.',
category: 'Navigation automation',
args: {
pageId: {
name: 'pageId',
type: 'number',
description: 'The ID of the page to select. Call list_pages to get available pages.',
required: true,
},
bringToFront: {
name: 'bringToFront',
type: 'boolean',
description: 'Whether to focus the page and bring it to the top.',
required: false,
},
},
},
take_memory_snapshot: {
description: 'Capture a memory heapsnapshot of the currently selected page to memory leak debugging',
category: 'Performance',
args: {
filePath: {
name: 'filePath',
type: 'string',
description: 'A path to a .heapsnapshot file to save the heapsnapshot to.',
required: true,
},
},
},
take_screenshot: {
description: 'Take a screenshot of the page or element.',
category: 'Debugging',
args: {
format: {
name: 'format',
type: 'string',
description: 'Type of format to save the screenshot as. Default is "png"',
required: false,
default: 'png',
enum: ['png', 'jpeg', 'webp'],
},
quality: {
name: 'quality',
type: 'number',
description: 'Compression quality for JPEG and WebP formats (0-100). Higher values mean better quality but larger file sizes. Ignored for PNG format.',
required: false,
},
uid: {
name: 'uid',
type: 'string',
description: 'The uid of an element on the page from the page content snapshot. If omitted takes a pages screenshot.',
required: false,
},
fullPage: {
name: 'fullPage',
type: 'boolean',
description: 'If set to true takes a screenshot of the full page instead of the currently visible viewport. Incompatible with uid.',
required: false,
},
filePath: {
name: 'filePath',
type: 'string',
description: 'The absolute path, or a path relative to the current working directory, to save the screenshot to instead of attaching it to the response.',
required: false,
},
},
},
take_snapshot: {
description: 'Take a text snapshot of the currently selected page based on the a11y tree. The snapshot lists page elements along with a unique\nidentifier (uid). Always use the latest snapshot. Prefer taking a snapshot over taking a screenshot. The snapshot indicates the element selected\nin the DevTools Elements panel (if any).',
category: 'Debugging',
args: {
verbose: {
name: 'verbose',
type: 'boolean',
description: 'Whether to include all possible information available in the full a11y tree. Default is false.',
required: false,
},
filePath: {
name: 'filePath',
type: 'string',
description: 'The absolute path, or a path relative to the current working directory, to save the snapshot to instead of attaching it to the response.',
required: false,
},
},
},
type_text: {
description: 'Type text using keyboard into a previously focused input',
category: 'Input automation',
args: {
text: {
name: 'text',
type: 'string',
description: 'The text to type',
required: true,
},
submitKey: {
name: 'submitKey',
type: 'string',
description: 'Optional key to press after typing. E.g., "Enter", "Tab", "Escape"',
required: false,
},
},
},
upload_file: {
description: 'Upload a file through a provided element.',
category: 'Input automation',
args: {
uid: {
name: 'uid',
type: 'string',
description: 'The uid of the file input element or an element that will open file chooser on the page from the page content snapshot',
required: true,
},
filePath: {
name: 'filePath',
type: 'string',
description: 'The local path of the file to upload',
required: true,
},
includeSnapshot: {
name: 'includeSnapshot',
type: 'boolean',
description: 'Whether to include a snapshot in the response. Default is false.',
required: false,
},
},
},
wait_for: {
description: 'Wait for the specified text to appear on the selected page.',
category: 'Navigation automation',
args: {
text: {
name: 'text',
type: 'array',
description: 'Non-empty list of texts. Resolves when any value appears on the page.',
required: true,
},
timeout: {
name: 'timeout',
type: 'integer',
description: 'Maximum wait time in milliseconds. If set to 0, the default timeout will be used.',
required: false,
},
},
},
};
@@ -0,0 +1,322 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { yargs, hideBin } from '../third_party/index.js';
export const cliOptions = {
autoConnect: {
type: 'boolean',
description: 'If specified, automatically connects to a browser (Chrome 144+) running locally from the user data directory identified by the channel param (default channel is stable). Requires the remoted debugging server to be started in the Chrome instance via chrome://inspect/#remote-debugging.',
conflicts: ['isolated', 'executablePath', 'categoryExtensions'],
default: false,
coerce: (value) => {
if (!value) {
return;
}
return value;
},
},
browserUrl: {
type: 'string',
description: 'Connect to a running, debuggable Chrome instance (e.g. `http://127.0.0.1:9222`). For more details see: https://github.com/ChromeDevTools/chrome-devtools-mcp#connecting-to-a-running-chrome-instance.',
alias: 'u',
conflicts: ['wsEndpoint', 'categoryExtensions'],
coerce: (url) => {
if (!url) {
return;
}
try {
new URL(url);
}
catch {
throw new Error(`Provided browserUrl ${url} is not valid URL.`);
}
return url;
},
},
wsEndpoint: {
type: 'string',
description: 'WebSocket endpoint to connect to a running Chrome instance (e.g., ws://127.0.0.1:9222/devtools/browser/<id>). Alternative to --browserUrl.',
alias: 'w',
conflicts: ['browserUrl', 'categoryExtensions'],
coerce: (url) => {
if (!url) {
return;
}
try {
const parsed = new URL(url);
if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') {
throw new Error(`Provided wsEndpoint ${url} must use ws:// or wss:// protocol.`);
}
return url;
}
catch (error) {
if (error.message.includes('ws://')) {
throw error;
}
throw new Error(`Provided wsEndpoint ${url} is not valid URL.`);
}
},
},
wsHeaders: {
type: 'string',
description: 'Custom headers for WebSocket connection in JSON format (e.g., \'{"Authorization":"Bearer token"}\'). Only works with --wsEndpoint.',
implies: 'wsEndpoint',
coerce: (val) => {
if (!val) {
return;
}
try {
const parsed = JSON.parse(val);
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Headers must be a JSON object');
}
return parsed;
}
catch (error) {
throw new Error(`Invalid JSON for wsHeaders: ${error.message}`);
}
},
},
headless: {
type: 'boolean',
description: 'Whether to run in headless (no UI) mode.',
default: false,
},
executablePath: {
type: 'string',
description: 'Path to custom Chrome executable.',
conflicts: ['browserUrl', 'wsEndpoint'],
alias: 'e',
},
isolated: {
type: 'boolean',
description: 'If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed. Defaults to false.',
},
userDataDir: {
type: 'string',
description: 'Path to the user data directory for Chrome. Default is $HOME/.cache/chrome-devtools-mcp/chrome-profile$CHANNEL_SUFFIX_IF_NON_STABLE',
conflicts: ['browserUrl', 'wsEndpoint', 'isolated'],
},
channel: {
type: 'string',
description: 'Specify a different Chrome channel that should be used. The default is the stable channel version.',
choices: ['stable', 'canary', 'beta', 'dev'],
conflicts: ['browserUrl', 'wsEndpoint', 'executablePath'],
},
logFile: {
type: 'string',
describe: 'Path to a file to write debug logs to. Set the env variable `DEBUG` to `*` to enable verbose logs. Useful for submitting bug reports.',
},
viewport: {
type: 'string',
describe: 'Initial viewport size for the Chrome instances started by the server. For example, `1280x720`. In headless mode, max size is 3840x2160px.',
coerce: (arg) => {
if (arg === undefined) {
return;
}
const [width, height] = arg.split('x').map(Number);
if (!width || !height || Number.isNaN(width) || Number.isNaN(height)) {
throw new Error('Invalid viewport. Expected format is `1280x720`.');
}
return {
width,
height,
};
},
},
proxyServer: {
type: 'string',
description: `Proxy server configuration for Chrome passed as --proxy-server when launching the browser. See https://www.chromium.org/developers/design-documents/network-settings/ for details.`,
},
acceptInsecureCerts: {
type: 'boolean',
description: `If enabled, ignores errors relative to self-signed and expired certificates. Use with caution.`,
},
experimentalPageIdRouting: {
type: 'boolean',
describe: 'Whether to expose pageId on page-scoped tools and route requests by page ID.',
hidden: true,
},
experimentalDevtools: {
type: 'boolean',
describe: 'Whether to enable automation over DevTools targets',
hidden: true,
},
experimentalVision: {
type: 'boolean',
describe: 'Whether to enable vision tools',
hidden: true,
},
experimentalStructuredContent: {
type: 'boolean',
describe: 'Whether to output structured formatted content.',
hidden: true,
},
experimentalIncludeAllPages: {
type: 'boolean',
describe: 'Whether to include all kinds of pages such as webviews or background pages as pages.',
hidden: true,
},
experimentalInteropTools: {
type: 'boolean',
describe: 'Whether to enable interoperability tools',
hidden: true,
},
experimentalScreencast: {
type: 'boolean',
describe: 'Exposes experimental screencast tools (requires ffmpeg). Install ffmpeg https://www.ffmpeg.org/download.html and ensure it is available in the MCP server PATH.',
},
chromeArg: {
type: 'array',
describe: 'Additional arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.',
},
ignoreDefaultChromeArg: {
type: 'array',
describe: 'Explicitly disable default arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.',
},
categoryEmulation: {
type: 'boolean',
default: true,
describe: 'Set to false to exclude tools related to emulation.',
},
categoryPerformance: {
type: 'boolean',
default: true,
describe: 'Set to false to exclude tools related to performance.',
},
categoryNetwork: {
type: 'boolean',
default: true,
describe: 'Set to false to exclude tools related to network.',
},
categoryExtensions: {
type: 'boolean',
hidden: true,
conflicts: ['browserUrl', 'autoConnect', 'wsEndpoint'],
describe: 'Set to true to include tools related to extensions. Note: This feature is only supported with a pipe connection. autoConnect is not supported.',
},
categoryInPageTools: {
type: 'boolean',
hidden: true,
describe: 'Set to true to enable tools exposed by the inspected page itself',
},
performanceCrux: {
type: 'boolean',
default: true,
describe: 'Set to false to disable sending URLs from performance traces to CrUX API to get field performance data.',
},
usageStatistics: {
type: 'boolean',
default: true,
describe: 'Set to false to opt-out of usage statistics collection. Google collects usage data to improve the tool, handled under the Google Privacy Policy (https://policies.google.com/privacy). This is independent from Chrome browser metrics. Disabled if CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS or CI env variables are set.',
},
clearcutEndpoint: {
type: 'string',
hidden: true,
describe: 'Endpoint for Clearcut telemetry.',
},
clearcutForceFlushIntervalMs: {
type: 'number',
hidden: true,
describe: 'Force flush interval in milliseconds (for testing).',
},
clearcutIncludePidHeader: {
type: 'boolean',
hidden: true,
describe: 'Include watchdog PID in Clearcut request headers (for testing).',
},
slim: {
type: 'boolean',
describe: 'Exposes a "slim" set of 3 tools covering navigation, script execution and screenshots only. Useful for basic browser tasks.',
},
viaCli: {
type: 'boolean',
describe: 'Set by Chrome DevTools CLI if the MCP server is started via the CLI client (this arg exists for usage stats)',
hidden: true,
},
};
export function parseArguments(version, argv = process.argv) {
const yargsInstance = yargs(hideBin(argv))
.scriptName('npx chrome-devtools-mcp@latest')
.options(cliOptions)
.check(args => {
// We can't set default in the options else
// Yargs will complain
if (!args.channel &&
!args.browserUrl &&
!args.wsEndpoint &&
!args.executablePath) {
args.channel = 'stable';
}
return true;
})
.example([
[
'$0 --browserUrl http://127.0.0.1:9222',
'Connect to an existing browser instance via HTTP',
],
[
'$0 --wsEndpoint ws://127.0.0.1:9222/devtools/browser/abc123',
'Connect to an existing browser instance via WebSocket',
],
[
`$0 --wsEndpoint ws://127.0.0.1:9222/devtools/browser/abc123 --wsHeaders '{"Authorization":"Bearer token"}'`,
'Connect via WebSocket with custom headers',
],
['$0 --channel beta', 'Use Chrome Beta installed on this system'],
['$0 --channel canary', 'Use Chrome Canary installed on this system'],
['$0 --channel dev', 'Use Chrome Dev installed on this system'],
['$0 --channel stable', 'Use stable Chrome installed on this system'],
['$0 --logFile /tmp/log.txt', 'Save logs to a file'],
['$0 --help', 'Print CLI options'],
[
'$0 --viewport 1280x720',
'Launch Chrome with the initial viewport size of 1280x720px',
],
[
`$0 --chrome-arg='--no-sandbox' --chrome-arg='--disable-setuid-sandbox'`,
'Launch Chrome without sandboxes. Use with caution.',
],
[
`$0 --ignore-default-chrome-arg='--disable-extensions'`,
'Disable the default arguments provided by Puppeteer. Use with caution.',
],
['$0 --no-category-emulation', 'Disable tools in the emulation category'],
[
'$0 --no-category-performance',
'Disable tools in the performance category',
],
['$0 --no-category-network', 'Disable tools in the network category'],
[
'$0 --user-data-dir=/tmp/user-data-dir',
'Use a custom user data directory',
],
[
'$0 --auto-connect',
'Connect to a stable Chrome instance (Chrome 144+) running instead of launching a new instance',
],
[
'$0 --auto-connect --channel=canary',
'Connect to a canary Chrome instance (Chrome 144+) running instead of launching a new instance',
],
[
'$0 --no-usage-statistics',
'Do not send usage statistics https://github.com/ChromeDevTools/chrome-devtools-mcp#usage-statistics.',
],
[
'$0 --no-performance-crux',
'Disable CrUX (field data) integration in performance tools.',
],
[
'$0 --slim',
'Only 3 tools: navigation, JavaScript execution and screenshot',
],
]);
return yargsInstance
.wrap(Math.min(120, yargsInstance.terminalWidth()))
.help()
.version(version)
.parseSync();
}
@@ -0,0 +1,35 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '../polyfill.js';
import process from 'node:process';
import { createMcpServer, logDisclaimers } from '../index.js';
import { logger, saveLogsToFile } from '../logger.js';
import { computeFlagUsage } from '../telemetry/flagUtils.js';
import { StdioServerTransport } from '../third_party/index.js';
import { VERSION } from '../version.js';
import { cliOptions, parseArguments } from './chrome-devtools-mcp-cli-options.js';
export const args = parseArguments(VERSION);
const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined;
if (process.env['CI'] ||
process.env['CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS']) {
console.error("turning off usage statistics. process.env['CI'] || process.env['CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS'] is set.");
args.usageStatistics = false;
}
if (process.env['CHROME_DEVTOOLS_MCP_CRASH_ON_UNCAUGHT'] !== 'true') {
process.on('unhandledRejection', (reason, promise) => {
logger('Unhandled promise rejection', promise, reason);
});
}
logger(`Starting Chrome DevTools MCP Server v${VERSION}`);
const { server, clearcutLogger } = await createMcpServer(args, {
logFile,
});
const transport = new StdioServerTransport();
await server.connect(transport);
logger('Chrome DevTools MCP Server connected');
logDisclaimers(args);
void clearcutLogger?.logDailyActiveIfNeeded();
void clearcutLogger?.logServerStart(computeFlagUsage(args, cliOptions));
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env node
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
process.title = 'chrome-devtools-mcp';
import { version } from 'node:process';
const [major, minor] = version.substring(1).split('.').map(Number);
if (major === 20 && minor < 19) {
console.error(`ERROR: \`chrome-devtools-mcp\` does not support Node ${process.version}. Please upgrade to Node 20.19.0 LTS or a newer LTS.`);
process.exit(1);
}
if (major === 22 && minor < 12) {
console.error(`ERROR: \`chrome-devtools-mcp\` does not support Node ${process.version}. Please upgrade to Node 22.12.0 LTS or a newer LTS.`);
process.exit(1);
}
if (major < 20) {
console.error(`ERROR: \`chrome-devtools-mcp\` does not support Node ${process.version}. Please upgrade to Node 20.19.0 LTS or a newer LTS.`);
process.exit(1);
}
await import('./chrome-devtools-mcp-main.js');
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env node
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
process.title = 'chrome-devtools';
import process from 'node:process';
import { startDaemon, stopDaemon, sendCommand, handleResponse, } from '../daemon/client.js';
import { isDaemonRunning, serializeArgs } from '../daemon/utils.js';
import { logDisclaimers } from '../index.js';
import { hideBin, yargs } from '../third_party/index.js';
import { VERSION } from '../version.js';
import { commands } from './chrome-devtools-cli-options.js';
import { cliOptions, parseArguments } from './chrome-devtools-mcp-cli-options.js';
async function start(args) {
const combinedArgs = [...args, ...defaultArgs];
await startDaemon(combinedArgs);
logDisclaimers(parseArguments(VERSION, combinedArgs));
}
const defaultArgs = ['--viaCli', '--experimentalStructuredContent'];
const startCliOptions = {
...cliOptions,
};
// Not supported in CLI on purpose.
delete startCliOptions.autoConnect;
// Missing CLI serialization.
delete startCliOptions.viewport;
// CLI is generated based on the default tool definitions. To enable conditional
// tools, they need to be enabled during CLI generation.
delete startCliOptions.experimentalPageIdRouting;
delete startCliOptions.experimentalVision;
delete startCliOptions.experimentalInteropTools;
delete startCliOptions.experimentalScreencast;
delete startCliOptions.categoryEmulation;
delete startCliOptions.categoryPerformance;
delete startCliOptions.categoryNetwork;
delete startCliOptions.categoryExtensions;
// Always on in CLI.
delete startCliOptions.experimentalStructuredContent;
// Change the defaults.
if (!('default' in cliOptions.headless)) {
throw new Error('headless cli option unexpectedly does not have a default');
}
if ('default' in cliOptions.isolated) {
throw new Error('isolated cli option unexpectedly has a default');
}
startCliOptions.headless.default = true;
startCliOptions.isolated.description =
'If specified, creates a temporary user-data-dir that is automatically cleaned up after the browser is closed. Defaults to true unless userDataDir is provided.';
const y = yargs(hideBin(process.argv))
.scriptName('chrome-devtools')
.showHelpOnFail(true)
.usage('chrome-devtools <command> [...args] --flags')
.usage(`Run 'chrome-devtools <command> --help' for help on the specific command.`)
.demandCommand()
.version(VERSION)
.strict()
.help(true)
.wrap(120);
y.command('start', 'Start or restart chrome-devtools-mcp', y => y
.options(startCliOptions)
.example('$0 start --browserUrl http://localhost:9222', 'Start the server connecting to an existing browser')
.strict(), async (argv) => {
if (isDaemonRunning()) {
await stopDaemon();
}
// Defaults but we do not want to affect the yargs conflict resolution.
if (argv.isolated === undefined && argv.userDataDir === undefined) {
argv.isolated = true;
}
if (argv.headless === undefined) {
argv.headless = true;
}
const args = serializeArgs(cliOptions, argv);
await start(args);
process.exit(0);
}).strict(); // Re-enable strict validation for other commands; this is applied to the yargs instance itself
y.command('status', 'Checks if chrome-devtools-mcp is running', async () => {
if (isDaemonRunning()) {
console.log('chrome-devtools-mcp daemon is running.');
const response = await sendCommand({
method: 'status',
});
if (response.success) {
const data = JSON.parse(response.result);
console.log(`pid=${data.pid} socket=${data.socketPath} start-date=${data.startDate} version=${data.version}`);
console.log(`args=${JSON.stringify(data.args)}`);
}
else {
console.error('Error:', response.error);
process.exit(1);
}
}
else {
console.log('chrome-devtools-mcp daemon is not running.');
}
process.exit(0);
});
y.command('stop', 'Stop chrome-devtools-mcp if any', async () => {
if (!isDaemonRunning()) {
process.exit(0);
}
await stopDaemon();
process.exit(0);
});
for (const [commandName, commandDef] of Object.entries(commands)) {
const args = commandDef.args;
const requiredArgNames = Object.keys(args).filter(name => args[name].required);
const optionalArgNames = Object.keys(args).filter(name => !args[name].required);
let commandStr = commandName;
for (const arg of requiredArgNames) {
commandStr += ` <${arg}>`;
}
for (const arg of optionalArgNames) {
commandStr += ` [--${arg}]`;
}
y.command(commandStr, commandDef.description, y => {
y.option('output-format', {
choices: ['md', 'json'],
default: 'md',
});
for (const [argName, opt] of Object.entries(args)) {
const type = opt.type === 'integer' || opt.type === 'number'
? 'number'
: opt.type === 'boolean'
? 'boolean'
: opt.type === 'array'
? 'array'
: 'string';
if (opt.required) {
const options = {
describe: opt.description,
type: type,
};
if (opt.default !== undefined) {
options.default = opt.default;
}
if (opt.enum) {
options.choices = opt.enum;
}
y.positional(argName, options);
}
else {
const options = {
describe: opt.description,
type: type,
};
if (opt.default !== undefined) {
options.default = opt.default;
}
if (opt.enum) {
options.choices = opt.enum;
}
y.option(argName, options);
}
}
}, async (argv) => {
try {
if (!isDaemonRunning()) {
await start([]);
}
const commandArgs = {};
for (const argName of Object.keys(args)) {
if (argName in argv) {
commandArgs[argName] = argv[argName];
}
}
const response = await sendCommand({
method: 'invoke_tool',
tool: commandName,
args: commandArgs,
});
if (response.success) {
console.log(await handleResponse(JSON.parse(response.result), argv['output-format']));
}
else {
console.error('Error:', response.error);
process.exit(1);
}
}
catch (error) {
console.error('Failed to execute command:', error);
process.exit(1);
}
});
}
await y.parse();
+615
View File
@@ -0,0 +1,615 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export const commands = {
click: {
description: 'Clicks on the provided element',
category: 'Input automation',
args: {
uid: {
name: 'uid',
type: 'string',
description: 'The uid of an element on the page from the page content snapshot',
required: true,
},
dblClick: {
name: 'dblClick',
type: 'boolean',
description: 'Set to true for double clicks. Default is false.',
required: false,
},
includeSnapshot: {
name: 'includeSnapshot',
type: 'boolean',
description: 'Whether to include a snapshot in the response. Default is false.',
required: false,
},
},
},
close_page: {
description: 'Closes the page by its index. The last open page cannot be closed.',
category: 'Navigation automation',
args: {
pageId: {
name: 'pageId',
type: 'number',
description: 'The ID of the page to close. Call list_pages to list pages.',
required: true,
},
},
},
drag: {
description: 'Drag an element onto another element',
category: 'Input automation',
args: {
from_uid: {
name: 'from_uid',
type: 'string',
description: 'The uid of the element to drag',
required: true,
},
to_uid: {
name: 'to_uid',
type: 'string',
description: 'The uid of the element to drop into',
required: true,
},
includeSnapshot: {
name: 'includeSnapshot',
type: 'boolean',
description: 'Whether to include a snapshot in the response. Default is false.',
required: false,
},
},
},
emulate: {
description: 'Emulates various features on the selected page.',
category: 'Emulation',
args: {
networkConditions: {
name: 'networkConditions',
type: 'string',
description: 'Throttle network. Omit to disable throttling.',
required: false,
enum: ['Offline', 'Slow 3G', 'Fast 3G', 'Slow 4G', 'Fast 4G'],
},
cpuThrottlingRate: {
name: 'cpuThrottlingRate',
type: 'number',
description: 'Represents the CPU slowdown factor. Omit or set the rate to 1 to disable throttling',
required: false,
},
geolocation: {
name: 'geolocation',
type: 'string',
description: 'Geolocation (`<latitude>x<longitude>`) to emulate. Latitude between -90 and 90. Longitude between -180 and 180. Omit clear the geolocation override.',
required: false,
},
userAgent: {
name: 'userAgent',
type: 'string',
description: 'User agent to emulate. Set to empty string to clear the user agent override.',
required: false,
},
colorScheme: {
name: 'colorScheme',
type: 'string',
description: 'Emulate the dark or the light mode. Set to "auto" to reset to the default.',
required: false,
enum: ['dark', 'light', 'auto'],
},
viewport: {
name: 'viewport',
type: 'string',
description: "Emulate device viewports '<width>x<height>x<devicePixelRatio>[,mobile][,touch][,landscape]'. 'touch' and 'mobile' to emulate mobile devices. 'landscape' to emulate landscape mode.",
required: false,
},
},
},
evaluate_script: {
description: 'Evaluate a JavaScript function inside the currently selected page. Returns the response as JSON,\nso returned values have to be JSON-serializable.',
category: 'Debugging',
args: {
function: {
name: 'function',
type: 'string',
description: 'A JavaScript function declaration to be executed by the tool in the currently selected page.\nExample without arguments: `() => {\n return document.title\n}` or `async () => {\n return await fetch("example.com")\n}`.\nExample with arguments: `(el) => {\n return el.innerText;\n}`\n',
required: true,
},
args: {
name: 'args',
type: 'array',
description: 'An optional list of arguments to pass to the function.',
required: false,
},
},
},
fill: {
description: 'Type text into a input, text area or select an option from a <select> element.',
category: 'Input automation',
args: {
uid: {
name: 'uid',
type: 'string',
description: 'The uid of an element on the page from the page content snapshot',
required: true,
},
value: {
name: 'value',
type: 'string',
description: 'The value to fill in',
required: true,
},
includeSnapshot: {
name: 'includeSnapshot',
type: 'boolean',
description: 'Whether to include a snapshot in the response. Default is false.',
required: false,
},
},
},
get_console_message: {
description: 'Gets a console message by its ID. You can get all messages by calling list_console_messages.',
category: 'Debugging',
args: {
msgid: {
name: 'msgid',
type: 'number',
description: 'The msgid of a console message on the page from the listed console messages',
required: true,
},
},
},
get_network_request: {
description: 'Gets a network request by an optional reqid, if omitted returns the currently selected request in the DevTools Network panel.',
category: 'Network',
args: {
reqid: {
name: 'reqid',
type: 'number',
description: 'The reqid of the network request. If omitted returns the currently selected request in the DevTools Network panel.',
required: false,
},
requestFilePath: {
name: 'requestFilePath',
type: 'string',
description: 'The absolute or relative path to save the request body to. If omitted, the body is returned inline.',
required: false,
},
responseFilePath: {
name: 'responseFilePath',
type: 'string',
description: 'The absolute or relative path to save the response body to. If omitted, the body is returned inline.',
required: false,
},
},
},
handle_dialog: {
description: 'If a browser dialog was opened, use this command to handle it',
category: 'Input automation',
args: {
action: {
name: 'action',
type: 'string',
description: 'Whether to dismiss or accept the dialog',
required: true,
enum: ['accept', 'dismiss'],
},
promptText: {
name: 'promptText',
type: 'string',
description: 'Optional prompt text to enter into the dialog.',
required: false,
},
},
},
hover: {
description: 'Hover over the provided element',
category: 'Input automation',
args: {
uid: {
name: 'uid',
type: 'string',
description: 'The uid of an element on the page from the page content snapshot',
required: true,
},
includeSnapshot: {
name: 'includeSnapshot',
type: 'boolean',
description: 'Whether to include a snapshot in the response. Default is false.',
required: false,
},
},
},
lighthouse_audit: {
description: 'Get Lighthouse score and reports for accessibility, SEO and best practices. This excludes performance. For performance audits, run performance_start_trace',
category: 'Debugging',
args: {
mode: {
name: 'mode',
type: 'string',
description: '"navigation" reloads & audits. "snapshot" analyzes current state.',
required: false,
default: 'navigation',
enum: ['navigation', 'snapshot'],
},
device: {
name: 'device',
type: 'string',
description: 'Device to emulate.',
required: false,
default: 'desktop',
enum: ['desktop', 'mobile'],
},
outputDirPath: {
name: 'outputDirPath',
type: 'string',
description: 'Directory for reports. If omitted, uses temporary files.',
required: false,
},
},
},
list_console_messages: {
description: 'List all console messages for the currently selected page since the last navigation.',
category: 'Debugging',
args: {
pageSize: {
name: 'pageSize',
type: 'integer',
description: 'Maximum number of messages to return. When omitted, returns all requests.',
required: false,
},
pageIdx: {
name: 'pageIdx',
type: 'integer',
description: 'Page number to return (0-based). When omitted, returns the first page.',
required: false,
},
types: {
name: 'types',
type: 'array',
description: 'Filter messages to only return messages of the specified resource types. When omitted or empty, returns all messages.',
required: false,
},
includePreservedMessages: {
name: 'includePreservedMessages',
type: 'boolean',
description: 'Set to true to return the preserved messages over the last 3 navigations.',
required: false,
default: false,
},
},
},
list_network_requests: {
description: 'List all requests for the currently selected page since the last navigation.',
category: 'Network',
args: {
pageSize: {
name: 'pageSize',
type: 'integer',
description: 'Maximum number of requests to return. When omitted, returns all requests.',
required: false,
},
pageIdx: {
name: 'pageIdx',
type: 'integer',
description: 'Page number to return (0-based). When omitted, returns the first page.',
required: false,
},
resourceTypes: {
name: 'resourceTypes',
type: 'array',
description: 'Filter requests to only return requests of the specified resource types. When omitted or empty, returns all requests.',
required: false,
},
includePreservedRequests: {
name: 'includePreservedRequests',
type: 'boolean',
description: 'Set to true to return the preserved requests over the last 3 navigations.',
required: false,
default: false,
},
},
},
list_pages: {
description: 'Get a list of pages open in the browser.',
category: 'Navigation automation',
args: {},
},
navigate_page: {
description: 'Go to a URL, or back, forward, or reload. Use project URL if not specified otherwise.',
category: 'Navigation automation',
args: {
type: {
name: 'type',
type: 'string',
description: 'Navigate the page by URL, back or forward in history, or reload.',
required: false,
enum: ['url', 'back', 'forward', 'reload'],
},
url: {
name: 'url',
type: 'string',
description: 'Target URL (only type=url)',
required: false,
},
ignoreCache: {
name: 'ignoreCache',
type: 'boolean',
description: 'Whether to ignore cache on reload.',
required: false,
},
handleBeforeUnload: {
name: 'handleBeforeUnload',
type: 'string',
description: 'Whether to auto accept or beforeunload dialogs triggered by this navigation. Default is accept.',
required: false,
enum: ['accept', 'decline'],
},
initScript: {
name: 'initScript',
type: 'string',
description: 'A JavaScript script to be executed on each new document before any other scripts for the next navigation.',
required: false,
},
timeout: {
name: 'timeout',
type: 'integer',
description: 'Maximum wait time in milliseconds. If set to 0, the default timeout will be used.',
required: false,
},
},
},
new_page: {
description: 'Open a new tab and load a URL. Use project URL if not specified otherwise.',
category: 'Navigation automation',
args: {
url: {
name: 'url',
type: 'string',
description: 'URL to load in a new page.',
required: true,
},
background: {
name: 'background',
type: 'boolean',
description: 'Whether to open the page in the background without bringing it to the front. Default is false (foreground).',
required: false,
},
isolatedContext: {
name: 'isolatedContext',
type: 'string',
description: 'If specified, the page is created in an isolated browser context with the given name. Pages in the same browser context share cookies and storage. Pages in different browser contexts are fully isolated.',
required: false,
},
timeout: {
name: 'timeout',
type: 'integer',
description: 'Maximum wait time in milliseconds. If set to 0, the default timeout will be used.',
required: false,
},
},
},
performance_analyze_insight: {
description: 'Provides more detailed information on a specific Performance Insight of an insight set that was highlighted in the results of a trace recording.',
category: 'Performance',
args: {
insightSetId: {
name: 'insightSetId',
type: 'string',
description: 'The id for the specific insight set. Only use the ids given in the "Available insight sets" list.',
required: true,
},
insightName: {
name: 'insightName',
type: 'string',
description: 'The name of the Insight you want more information on. For example: "DocumentLatency" or "LCPBreakdown"',
required: true,
},
},
},
performance_start_trace: {
description: 'Start a performance trace on the selected webpage. Use to find frontend performance issues, Core Web Vitals (LCP, INP, CLS), and improve page load speed.',
category: 'Performance',
args: {
reload: {
name: 'reload',
type: 'boolean',
description: 'Determines if, once tracing has started, the current selected page should be automatically reloaded. Navigate the page to the right URL using the navigate_page tool BEFORE starting the trace if reload or autoStop is set to true.',
required: false,
default: true,
},
autoStop: {
name: 'autoStop',
type: 'boolean',
description: 'Determines if the trace recording should be automatically stopped.',
required: false,
default: true,
},
filePath: {
name: 'filePath',
type: 'string',
description: 'The absolute file path, or a file path relative to the current working directory, to save the raw trace data. For example, trace.json.gz (compressed) or trace.json (uncompressed).',
required: false,
},
},
},
performance_stop_trace: {
description: 'Stop the active performance trace recording on the selected webpage.',
category: 'Performance',
args: {
filePath: {
name: 'filePath',
type: 'string',
description: 'The absolute file path, or a file path relative to the current working directory, to save the raw trace data. For example, trace.json.gz (compressed) or trace.json (uncompressed).',
required: false,
},
},
},
press_key: {
description: 'Press a key or key combination. Use this when other input methods like fill() cannot be used (e.g., keyboard shortcuts, navigation keys, or special key combinations).',
category: 'Input automation',
args: {
key: {
name: 'key',
type: 'string',
description: 'A key or a combination (e.g., "Enter", "Control+A", "Control++", "Control+Shift+R"). Modifiers: Control, Shift, Alt, Meta',
required: true,
},
includeSnapshot: {
name: 'includeSnapshot',
type: 'boolean',
description: 'Whether to include a snapshot in the response. Default is false.',
required: false,
},
},
},
resize_page: {
description: "Resizes the selected page's window so that the page has specified dimension",
category: 'Emulation',
args: {
width: {
name: 'width',
type: 'number',
description: 'Page width',
required: true,
},
height: {
name: 'height',
type: 'number',
description: 'Page height',
required: true,
},
},
},
select_page: {
description: 'Select a page as a context for future tool calls.',
category: 'Navigation automation',
args: {
pageId: {
name: 'pageId',
type: 'number',
description: 'The ID of the page to select. Call list_pages to get available pages.',
required: true,
},
bringToFront: {
name: 'bringToFront',
type: 'boolean',
description: 'Whether to focus the page and bring it to the top.',
required: false,
},
},
},
take_memory_snapshot: {
description: 'Capture a heap snapshot of the currently selected page. Use to analyze the memory distribution of JavaScript objects and debug memory leaks.',
category: 'Performance',
args: {
filePath: {
name: 'filePath',
type: 'string',
description: 'A path to a .heapsnapshot file to save the heapsnapshot to.',
required: true,
},
},
},
take_screenshot: {
description: 'Take a screenshot of the page or element.',
category: 'Debugging',
args: {
format: {
name: 'format',
type: 'string',
description: 'Type of format to save the screenshot as. Default is "png"',
required: false,
default: 'png',
enum: ['png', 'jpeg', 'webp'],
},
quality: {
name: 'quality',
type: 'number',
description: 'Compression quality for JPEG and WebP formats (0-100). Higher values mean better quality but larger file sizes. Ignored for PNG format.',
required: false,
},
uid: {
name: 'uid',
type: 'string',
description: 'The uid of an element on the page from the page content snapshot. If omitted takes a pages screenshot.',
required: false,
},
fullPage: {
name: 'fullPage',
type: 'boolean',
description: 'If set to true takes a screenshot of the full page instead of the currently visible viewport. Incompatible with uid.',
required: false,
},
filePath: {
name: 'filePath',
type: 'string',
description: 'The absolute path, or a path relative to the current working directory, to save the screenshot to instead of attaching it to the response.',
required: false,
},
},
},
take_snapshot: {
description: 'Take a text snapshot of the currently selected page based on the a11y tree. The snapshot lists page elements along with a unique\nidentifier (uid). Always use the latest snapshot. Prefer taking a snapshot over taking a screenshot. The snapshot indicates the element selected\nin the DevTools Elements panel (if any).',
category: 'Debugging',
args: {
verbose: {
name: 'verbose',
type: 'boolean',
description: 'Whether to include all possible information available in the full a11y tree. Default is false.',
required: false,
},
filePath: {
name: 'filePath',
type: 'string',
description: 'The absolute path, or a path relative to the current working directory, to save the snapshot to instead of attaching it to the response.',
required: false,
},
},
},
type_text: {
description: 'Type text using keyboard into a previously focused input',
category: 'Input automation',
args: {
text: {
name: 'text',
type: 'string',
description: 'The text to type',
required: true,
},
submitKey: {
name: 'submitKey',
type: 'string',
description: 'Optional key to press after typing. E.g., "Enter", "Tab", "Escape"',
required: false,
},
},
},
upload_file: {
description: 'Upload a file through a provided element.',
category: 'Input automation',
args: {
uid: {
name: 'uid',
type: 'string',
description: 'The uid of the file input element or an element that will open file chooser on the page from the page content snapshot',
required: true,
},
filePath: {
name: 'filePath',
type: 'string',
description: 'The local path of the file to upload',
required: true,
},
includeSnapshot: {
name: 'includeSnapshot',
type: 'boolean',
description: 'Whether to include a snapshot in the response. Default is false.',
required: false,
},
},
},
};
+203
View File
@@ -0,0 +1,203 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { logger } from './logger.js';
import { puppeteer } from './third_party/index.js';
let browser;
function makeTargetFilter(enableExtensions = false) {
const ignoredPrefixes = new Set(['chrome://', 'chrome-untrusted://']);
if (!enableExtensions) {
ignoredPrefixes.add('chrome-extension://');
}
return function targetFilter(target) {
if (target.url() === 'chrome://newtab/') {
return true;
}
// Could be the only page opened in the browser.
if (target.url().startsWith('chrome://inspect')) {
return true;
}
for (const prefix of ignoredPrefixes) {
if (target.url().startsWith(prefix)) {
return false;
}
}
return true;
};
}
export async function ensureBrowserConnected(options) {
const { channel, enableExtensions } = options;
if (browser?.connected) {
return browser;
}
const connectOptions = {
targetFilter: makeTargetFilter(enableExtensions),
defaultViewport: null,
handleDevToolsAsPage: true,
};
let autoConnect = false;
if (options.wsEndpoint) {
connectOptions.browserWSEndpoint = options.wsEndpoint;
if (options.wsHeaders) {
connectOptions.headers = options.wsHeaders;
}
}
else if (options.browserURL) {
connectOptions.browserURL = options.browserURL;
}
else if (channel || options.userDataDir) {
const userDataDir = options.userDataDir;
if (userDataDir) {
autoConnect = true;
// TODO: re-expose this logic via Puppeteer.
const portPath = path.join(userDataDir, 'DevToolsActivePort');
try {
const fileContent = await fs.promises.readFile(portPath, 'utf8');
const [rawPort, rawPath] = fileContent
.split('\n')
.map(line => {
return line.trim();
})
.filter(line => {
return !!line;
});
if (!rawPort || !rawPath) {
throw new Error(`Invalid DevToolsActivePort '${fileContent}' found`);
}
const port = parseInt(rawPort, 10);
if (isNaN(port) || port <= 0 || port > 65535) {
throw new Error(`Invalid port '${rawPort}' found`);
}
const browserWSEndpoint = `ws://127.0.0.1:${port}${rawPath}`;
connectOptions.browserWSEndpoint = browserWSEndpoint;
}
catch (error) {
throw new Error(`Could not connect to Chrome in ${userDataDir}. Check if Chrome is running and remote debugging is enabled by going to chrome://inspect/#remote-debugging.`, {
cause: error,
});
}
}
else {
if (!channel) {
throw new Error('Channel must be provided if userDataDir is missing');
}
connectOptions.channel = (channel === 'stable' ? 'chrome' : `chrome-${channel}`);
}
}
else {
throw new Error('Either browserURL, wsEndpoint, channel or userDataDir must be provided');
}
logger('Connecting Puppeteer to ', JSON.stringify(connectOptions));
try {
browser = await puppeteer.connect(connectOptions);
}
catch (err) {
throw new Error(`Could not connect to Chrome. ${autoConnect ? `Check if Chrome is running and remote debugging is enabled by going to chrome://inspect/#remote-debugging.` : `Check if Chrome is running.`}`, {
cause: err,
});
}
logger('Connected Puppeteer');
return browser;
}
export function detectDisplay() {
// Only detect display on Linux/UNIX.
if (os.platform() === 'win32' || os.platform() === 'darwin') {
return;
}
if (!process.env['DISPLAY']) {
try {
const result = execSync(`ps -u $(id -u) -o pid= | xargs -I{} cat /proc/{}/environ 2>/dev/null | tr '\\0' '\\n' | grep -m1 '^DISPLAY=' | cut -d= -f2`);
const display = result.toString('utf8').trim();
process.env['DISPLAY'] = display;
}
catch {
// no-op
}
}
}
export async function launch(options) {
const { channel, executablePath, headless, isolated } = options;
const profileDirName = channel && channel !== 'stable'
? `chrome-profile-${channel}`
: 'chrome-profile';
let userDataDir = options.userDataDir;
if (!isolated && !userDataDir) {
userDataDir = path.join(os.homedir(), '.cache', options.viaCli ? 'chrome-devtools-mcp-cli' : 'chrome-devtools-mcp', profileDirName);
await fs.promises.mkdir(userDataDir, {
recursive: true,
});
}
const args = [
...(options.chromeArgs ?? []),
'--hide-crash-restore-bubble',
];
const ignoreDefaultArgs = options.ignoreDefaultChromeArgs ?? false;
if (headless) {
args.push('--screen-info={3840x2160}');
}
let puppeteerChannel;
if (options.devtools) {
args.push('--auto-open-devtools-for-tabs');
}
if (!executablePath) {
puppeteerChannel =
channel && channel !== 'stable'
? `chrome-${channel}`
: 'chrome';
}
if (!headless) {
detectDisplay();
}
try {
const browser = await puppeteer.launch({
channel: puppeteerChannel,
targetFilter: makeTargetFilter(options.enableExtensions),
executablePath,
defaultViewport: null,
userDataDir,
pipe: true,
headless,
args,
ignoreDefaultArgs: ignoreDefaultArgs,
acceptInsecureCerts: options.acceptInsecureCerts,
handleDevToolsAsPage: true,
enableExtensions: options.enableExtensions,
});
if (options.logFile) {
// FIXME: we are probably subscribing too late to catch startup logs. We
// should expose the process earlier or expose the getRecentLogs() getter.
browser.process()?.stderr?.pipe(options.logFile);
browser.process()?.stdout?.pipe(options.logFile);
}
if (options.viewport) {
const [page] = await browser.pages();
await page?.resize({
contentWidth: options.viewport.width,
contentHeight: options.viewport.height,
});
}
return browser;
}
catch (error) {
if (userDataDir &&
error.message.includes('The browser is already running')) {
throw new Error(`The browser is already running for ${userDataDir}. Use --isolated to run multiple browser instances.`, {
cause: error,
});
}
throw error;
}
}
export async function ensureBrowserLaunched(options) {
if (browser?.connected) {
return browser;
}
browser = await launch(options);
return browser;
}
+152
View File
@@ -0,0 +1,152 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import net from 'node:net';
import { logger } from '../logger.js';
import { PipeTransport } from '../third_party/index.js';
import { saveTemporaryFile } from '../utils/files.js';
import { DAEMON_SCRIPT_PATH, getSocketPath, getPidFilePath, isDaemonRunning, } from './utils.js';
const FILE_TIMEOUT = 10_000;
/**
* Waits for a file to be created and populated (removed = false) or removed (removed = true).
*/
function waitForFile(filePath, removed = false) {
return new Promise((resolve, reject) => {
const check = () => {
const exists = fs.existsSync(filePath);
if (removed) {
return !exists;
}
if (!exists) {
return false;
}
try {
return fs.statSync(filePath).size > 0;
}
catch {
return false;
}
};
if (check()) {
resolve();
return;
}
const timer = setTimeout(() => {
fs.unwatchFile(filePath);
reject(new Error(`Timeout: file ${filePath} ${removed ? 'not removed' : 'not found'} within ${FILE_TIMEOUT}ms`));
}, FILE_TIMEOUT);
fs.watchFile(filePath, { interval: 500 }, () => {
if (check()) {
clearTimeout(timer);
fs.unwatchFile(filePath);
resolve();
}
});
});
}
export async function startDaemon(mcpArgs = []) {
if (isDaemonRunning()) {
logger('Daemon is already running');
return;
}
const pidFilePath = getPidFilePath();
if (fs.existsSync(pidFilePath)) {
fs.unlinkSync(pidFilePath);
}
logger('Starting daemon...', ...mcpArgs);
const child = spawn(process.execPath, [DAEMON_SCRIPT_PATH, ...mcpArgs], {
detached: true,
stdio: 'ignore',
env: process.env,
cwd: process.cwd(),
windowsHide: true,
});
child.unref();
await waitForFile(pidFilePath);
}
const SEND_COMMAND_TIMEOUT = 60_000; // ms
/**
* `sendCommand` opens a socket connection sends a single command and disconnects.
*/
export async function sendCommand(command) {
const socketPath = getSocketPath();
const socket = net.createConnection({
path: socketPath,
});
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
socket.destroy();
reject(new Error('Timeout waiting for daemon response'));
}, SEND_COMMAND_TIMEOUT);
const transport = new PipeTransport(socket, socket);
transport.onmessage = async (message) => {
clearTimeout(timer);
logger('onmessage', message);
resolve(JSON.parse(message));
};
socket.on('error', error => {
clearTimeout(timer);
logger('Socket error:', error);
reject(error);
});
socket.on('close', () => {
clearTimeout(timer);
logger('Socket closed:');
reject(new Error('Socket closed'));
});
logger('Sending message', command);
transport.send(JSON.stringify(command));
});
}
export async function stopDaemon() {
if (!isDaemonRunning()) {
logger('Daemon is not running');
return;
}
const pidFilePath = getPidFilePath();
await sendCommand({ method: 'stop' });
await waitForFile(pidFilePath, /*removed=*/ true);
}
export async function handleResponse(response, format) {
if (response.isError) {
return JSON.stringify(response.content);
}
if (format === 'json') {
if (response.structuredContent) {
return JSON.stringify(response.structuredContent);
}
// Fall-through to text for backward compatibility.
}
const chunks = [];
for (const content of response.content) {
if (content.type === 'text') {
chunks.push(content.text);
}
else if (content.type === 'image') {
const imageData = content.data;
const mimeType = content.mimeType;
let extension = '.png';
switch (mimeType) {
case 'image/jpg':
case 'image/jpeg':
extension = '.jpeg';
break;
case 'webp':
extension = '.webp';
break;
}
const data = Buffer.from(imageData, 'base64');
const name = crypto.randomUUID();
const { filepath } = await saveTemporaryFile(data, `${name}${extension}`);
chunks.push(`Saved to ${filepath}.`);
}
else {
throw new Error('Not supported response content type');
}
}
return format === 'md' ? chunks.join(' ') : JSON.stringify(chunks);
}
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env node
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import { createServer } from 'node:net';
import path from 'node:path';
import process from 'node:process';
import { logger } from '../logger.js';
import { Client, PipeTransport, StdioClientTransport, } from '../third_party/index.js';
import { VERSION } from '../version.js';
import { DAEMON_CLIENT_NAME, getDaemonPid, getPidFilePath, getSocketPath, INDEX_SCRIPT_PATH, IS_WINDOWS, isDaemonRunning, } from './utils.js';
const pid = getDaemonPid();
if (isDaemonRunning(pid)) {
logger('Another daemon process is running.');
process.exit(1);
}
const pidFilePath = getPidFilePath();
fs.mkdirSync(path.dirname(pidFilePath), {
recursive: true,
});
fs.writeFileSync(pidFilePath, process.pid.toString());
logger(`Writing ${process.pid.toString()} to ${pidFilePath}`);
const socketPath = getSocketPath();
const startDate = new Date();
const mcpServerArgs = process.argv.slice(2);
let mcpClient = null;
let mcpTransport = null;
let server = null;
async function setupMCPClient() {
console.log('Setting up MCP client connection...');
// Create stdio transport for chrome-devtools-mcp
// Workaround for https://github.com/modelcontextprotocol/typescript-sdk/blob/v1.x/src/client/stdio.ts#L128
// which causes the console window to show on Windows.
// @ts-expect-error no types for type.
process.type = 'mcp-client';
mcpTransport = new StdioClientTransport({
command: process.execPath,
args: [INDEX_SCRIPT_PATH, ...mcpServerArgs],
env: process.env,
});
mcpClient = new Client({
name: DAEMON_CLIENT_NAME,
version: VERSION,
}, {
capabilities: {},
});
await mcpClient.connect(mcpTransport);
console.log('MCP client connected');
}
async function handleRequest(msg) {
try {
if (msg.method === 'invoke_tool') {
if (!mcpClient) {
throw new Error('MCP client not initialized');
}
const { tool, args } = msg;
const result = (await mcpClient.callTool({
name: tool,
arguments: args || {},
}));
return {
success: true,
result: JSON.stringify(result),
};
}
else if (msg.method === 'stop') {
// Ensure we are not interrupting in-progress starting.
await started;
// Trigger cleanup asynchronously.
setImmediate(() => {
void cleanup();
});
return {
success: true,
message: 'stopping',
};
}
else if (msg.method === 'status') {
return {
success: true,
result: JSON.stringify({
pid: process.pid,
socketPath,
startDate: startDate.toISOString(),
version: VERSION,
args: mcpServerArgs,
}),
};
}
{
return {
success: false,
error: `Unknown method: ${JSON.stringify(msg, null, 2)}`,
};
}
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
error: errorMessage,
};
}
}
async function startSocketServer() {
// Remove existing socket file if it exists (only on non-Windows)
if (!IS_WINDOWS) {
try {
fs.unlinkSync(socketPath);
}
catch {
// ignore errors.
}
}
return await new Promise((resolve, reject) => {
server = createServer(socket => {
const transport = new PipeTransport(socket, socket);
transport.onmessage = async (message) => {
logger('onmessage', message);
const response = await handleRequest(JSON.parse(message));
transport.send(JSON.stringify(response));
socket.end();
};
socket.on('error', error => {
logger('Socket error:', error);
});
});
server.listen({
path: socketPath,
readableAll: false,
writableAll: false,
}, async () => {
console.log(`Daemon server listening on ${socketPath}`);
try {
// Setup MCP client
await setupMCPClient();
resolve();
}
catch (err) {
reject(err);
}
});
server.on('error', error => {
logger('Server error:', error);
reject(error);
});
});
}
async function cleanup() {
console.log('Cleaning up daemon...');
try {
await mcpClient?.close();
}
catch (error) {
logger('Error closing MCP client:', error);
}
try {
await mcpTransport?.close();
}
catch (error) {
logger('Error closing MCP transport:', error);
}
if (server) {
await new Promise(resolve => {
server.close(() => resolve());
});
}
if (!IS_WINDOWS) {
try {
fs.unlinkSync(socketPath);
}
catch {
// ignore errors
}
}
logger(`unlinking ${pidFilePath}`);
if (fs.existsSync(pidFilePath)) {
fs.unlinkSync(pidFilePath);
}
process.exit(0);
}
// Handle shutdown signals
process.on('SIGTERM', () => {
void cleanup();
});
process.on('SIGINT', () => {
void cleanup();
});
process.on('SIGHUP', () => {
void cleanup();
});
// Handle uncaught errors
process.on('uncaughtException', error => {
logger('Uncaught exception:', error);
});
process.on('unhandledRejection', error => {
logger('Unhandled rejection:', error);
});
// Start the server
const started = startSocketServer().catch(error => {
logger('Failed to start daemon server:', error);
process.exit(1);
});
+6
View File
@@ -0,0 +1,6 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export {};
+109
View File
@@ -0,0 +1,109 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { logger } from '../logger.js';
export const DAEMON_SCRIPT_PATH = path.join(import.meta.dirname, 'daemon.js');
export const INDEX_SCRIPT_PATH = path.join(import.meta.dirname, '..', 'bin', 'chrome-devtools-mcp.js');
const APP_NAME = 'chrome-devtools-mcp';
export const DAEMON_CLIENT_NAME = 'chrome-devtools-cli-daemon';
// Using these paths due to strict limits on the POSIX socket path length.
export function getSocketPath() {
const uid = os.userInfo().uid;
if (IS_WINDOWS) {
// Windows uses Named Pipes, not file paths.
// This format is required for server.listen()
return path.join('\\\\.\\pipe', APP_NAME, 'server.sock');
}
// 1. Try XDG_RUNTIME_DIR (Linux standard, sometimes macOS)
if (process.env.XDG_RUNTIME_DIR) {
return path.join(process.env.XDG_RUNTIME_DIR, APP_NAME, 'server.sock');
}
// 2. macOS/Unix Fallback: Use /tmp/
// We use /tmp/ because it is much shorter than ~/Library/Application Support/
// and keeps us well under the 104-character limit.
return path.join('/tmp', `${APP_NAME}-${uid}.sock`);
}
export function getRuntimeHome() {
const platform = os.platform();
const uid = os.userInfo().uid;
// 1. Check for the modern Unix standard
if (process.env.XDG_RUNTIME_DIR) {
return path.join(process.env.XDG_RUNTIME_DIR, APP_NAME);
}
// 2. Fallback for macOS and older Linux
if (platform === 'darwin' || platform === 'linux') {
// /tmp is cleared on boot, making it perfect for PIDs
return path.join('/tmp', `${APP_NAME}-${uid}`);
}
// 3. Windows Fallback
return path.join(os.tmpdir(), APP_NAME);
}
export const IS_WINDOWS = os.platform() === 'win32';
export function getPidFilePath() {
const runtimeDir = getRuntimeHome();
return path.join(runtimeDir, 'daemon.pid');
}
export function getDaemonPid() {
try {
const pidFile = getPidFilePath();
logger(`Daemon pid file ${pidFile}`);
if (!fs.existsSync(pidFile)) {
return null;
}
const pidContent = fs.readFileSync(pidFile, 'utf-8');
const pid = parseInt(pidContent.trim(), 10);
logger(`Daemon pid: ${pid}`);
if (isNaN(pid)) {
return null;
}
return pid;
}
catch {
return null;
}
}
export function isDaemonRunning(pid = getDaemonPid()) {
if (pid) {
try {
process.kill(pid, 0); // Throws if process doesn't exist
return true;
}
catch {
// Process is dead, stale PID file. Proceed with startup.
}
}
return false;
}
export function serializeArgs(options, argv) {
const args = [];
for (const key of Object.keys(options)) {
if (argv[key] === undefined || argv[key] === null) {
continue;
}
const value = argv[key];
const kebabKey = key.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`);
if (typeof value === 'boolean') {
if (value) {
args.push(`--${kebabKey}`);
}
else {
args.push(`--no-${kebabKey}`);
}
}
else if (Array.isArray(value)) {
for (const item of value) {
args.push(`--${kebabKey}`, String(item));
}
}
else {
args.push(`--${kebabKey}`, String(value));
}
}
return args;
}
@@ -0,0 +1,241 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { createStackTraceForConsoleMessage, SymbolizedError, } from '../DevtoolsUtils.js';
import { UncaughtError } from '../PageCollector.js';
import * as DevTools from '../third_party/index.js';
export class ConsoleFormatter {
#id;
#type;
#text;
#argCount;
#resolvedArgs;
#stack;
#cause;
isIgnored;
constructor(params) {
this.#id = params.id;
this.#type = params.type;
this.#text = params.text;
this.#argCount = params.argCount ?? 0;
this.#resolvedArgs = params.resolvedArgs ?? [];
this.#stack = params.stack;
this.#cause = params.cause;
this.isIgnored = params.isIgnored;
}
static async from(msg, options) {
const ignoreListManager = options?.devTools?.universe.context.get(DevTools.DevTools.IgnoreListManager);
const isIgnored = options.isIgnoredForTesting ||
(frame => {
if (!ignoreListManager) {
return false;
}
if (frame.uiSourceCode) {
return ignoreListManager.isUserOrSourceMapIgnoreListedUISourceCode(frame.uiSourceCode);
}
if (frame.url) {
return ignoreListManager.isUserIgnoreListedURL(frame.url);
}
return false;
});
if (msg instanceof UncaughtError) {
const error = await SymbolizedError.fromDetails({
devTools: options?.devTools,
details: msg.details,
targetId: msg.targetId,
includeStackAndCause: options?.fetchDetailedData,
resolvedStackTraceForTesting: options?.resolvedStackTraceForTesting,
resolvedCauseForTesting: options?.resolvedCauseForTesting,
});
return new ConsoleFormatter({
id: options.id,
type: 'error',
text: error.message,
stack: error.stackTrace,
cause: error.cause,
isIgnored,
});
}
let resolvedArgs = [];
if (options.resolvedArgsForTesting) {
resolvedArgs = options.resolvedArgsForTesting;
}
else if (options.fetchDetailedData) {
resolvedArgs = await Promise.all(msg.args().map(async (arg, i) => {
try {
const remoteObject = arg.remoteObject();
if (remoteObject.type === 'object' &&
remoteObject.subtype === 'error') {
return await SymbolizedError.fromError({
devTools: options.devTools,
error: remoteObject,
// @ts-expect-error Internal ConsoleMessage API
targetId: msg._targetId(),
});
}
return await arg.jsonValue();
}
catch {
return `<error: Argument ${i} is no longer available>`;
}
}));
}
let stack;
if (options.resolvedStackTraceForTesting) {
stack = options.resolvedStackTraceForTesting;
}
else if (options.fetchDetailedData && options.devTools) {
try {
stack = await createStackTraceForConsoleMessage(options.devTools, msg);
}
catch {
// ignore
}
}
return new ConsoleFormatter({
id: options.id,
type: msg.type(),
text: msg.text(),
argCount: resolvedArgs.length || msg.args().length,
resolvedArgs,
stack,
isIgnored,
});
}
// The short format for a console message.
toString() {
return convertConsoleMessageConciseToString(this.toJSON());
}
// The verbose format for a console message, including all details.
toStringDetailed() {
return convertConsoleMessageConciseDetailedToString(this.toJSONDetailed());
}
#getArgs() {
if (this.#resolvedArgs.length > 0) {
const args = [...this.#resolvedArgs];
// If there is no text, the first argument serves as text (see formatMessage).
if (!this.#text) {
args.shift();
}
return args;
}
return [];
}
toJSON() {
return {
type: this.#type,
text: this.#text,
argsCount: this.#argCount,
id: this.#id,
};
}
toJSONDetailed() {
return {
id: this.#id,
type: this.#type,
text: this.#text,
argsCount: this.#argCount,
args: this.#getArgs().map(arg => formatArg(arg, this)),
stackTrace: this.#stack
? formatStackTrace(this.#stack, this.#cause, this)
: undefined,
};
}
}
function convertConsoleMessageConciseToString(msg) {
return `msgid=${msg.id} [${msg.type}] ${msg.text} (${msg.argsCount} args)`;
}
function convertConsoleMessageConciseDetailedToString(msg) {
const result = [
`ID: ${msg.id}`,
`Message: ${msg.type}> ${msg.text}`,
formatArgs(msg),
...(msg.stackTrace ? ['### Stack trace', msg.stackTrace] : []),
].filter(line => !!line);
return result.join('\n');
}
function formatArgs(msg) {
const args = msg.args;
if (!args.length) {
return '';
}
const result = ['### Arguments'];
for (const [key, arg] of args.entries()) {
result.push(`Arg #${key}: ${arg}`);
}
return result.join('\n');
}
function formatArg(arg, formatter) {
if (arg instanceof SymbolizedError) {
return [
arg.message,
arg.stackTrace
? formatStackTrace(arg.stackTrace, arg.cause, formatter)
: undefined,
]
.filter(line => !!line)
.join('\n');
}
return typeof arg === 'object' ? JSON.stringify(arg) : String(arg);
}
const STACK_TRACE_MAX_LINES = 50;
function formatStackTrace(stackTrace, cause, formatter) {
const lines = formatStackTraceInner(stackTrace, cause, formatter);
const includedLines = lines.slice(0, STACK_TRACE_MAX_LINES);
const reminderCount = lines.length - includedLines.length;
return [
...includedLines,
reminderCount > 0 ? `... and ${reminderCount} more frames` : '',
'Note: line and column numbers use 1-based indexing',
]
.filter(line => !!line)
.join('\n');
}
function formatStackTraceInner(stackTrace, cause, formatter) {
if (!stackTrace) {
return [];
}
return [
...formatFragment(stackTrace.syncFragment, formatter),
...stackTrace.asyncFragments
.map(item => formatAsyncFragment(item, formatter))
.flat(),
...formatCause(cause, formatter),
];
}
function formatFragment(fragment, formatter) {
const frames = fragment.frames.filter(frame => !formatter.isIgnored(frame));
return frames.map(formatFrame);
}
function formatAsyncFragment(fragment, formatter) {
const formattedFrames = formatFragment(fragment, formatter);
if (formattedFrames.length === 0) {
return [];
}
const separatorLineLength = 40;
const prefix = `--- ${fragment.description || 'async'} `;
const separator = prefix + '-'.repeat(separatorLineLength - prefix.length);
return [separator, ...formattedFrames];
}
function formatFrame(frame) {
let result = `at ${frame.name ?? '<anonymous>'}`;
if (frame.uiSourceCode) {
const location = frame.uiSourceCode.uiLocation(frame.line, frame.column);
result += ` (${location.linkText(/* skipTrim */ false, /* showColumnNumber */ true)})`;
}
else if (frame.url) {
result += ` (${frame.url}:${frame.line}:${frame.column})`;
}
return result;
}
function formatCause(cause, formatter) {
if (!cause) {
return [];
}
return [
`Caused by: ${cause.message}`,
...formatStackTraceInner(cause.stackTrace, cause.cause, formatter),
];
}
+192
View File
@@ -0,0 +1,192 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { ISSUE_UTILS } from '../issue-descriptions.js';
import { logger } from '../logger.js';
import { DevTools } from '../third_party/index.js';
export class IssueFormatter {
#issue;
#options;
constructor(issue, options) {
this.#issue = issue;
this.#options = options;
}
toString() {
return convertIssueConciseToString(this.toJSON());
}
toStringDetailed() {
return convertIssueDetailedToString(this.toJSONDetailed());
}
toJSON() {
return {
type: 'issue',
title: this.#getTitle(),
count: this.#issue.getAggregatedIssuesCount(),
id: this.#options.id,
};
}
toJSONDetailed() {
return {
id: this.#options.id,
type: 'issue',
count: this.#issue.getAggregatedIssuesCount(),
title: this.#getTitle(),
description: this.#getDescription(),
links: this.#issue.getDescription()?.links,
affectedResources: this.#getAffectedResources(),
};
}
#getAffectedResources() {
const issues = this.#issue.getAllIssues();
const affectedResources = [];
for (const singleIssue of issues) {
const details = singleIssue.details();
if (!details) {
continue;
}
// We send the remaining details as untyped JSON because the DevTools
// frontend code is currently not re-usable.
const data = structuredClone(details);
let uid;
let request;
if ('violatingNodeId' in details &&
details.violatingNodeId &&
this.#options.elementIdResolver) {
uid = this.#options.elementIdResolver(details.violatingNodeId);
delete data.violatingNodeId;
}
if ('nodeId' in details &&
details.nodeId &&
this.#options.elementIdResolver) {
uid = this.#options.elementIdResolver(details.nodeId);
delete data.nodeId;
}
if ('documentNodeId' in details &&
details.documentNodeId &&
this.#options.elementIdResolver) {
uid = this.#options.elementIdResolver(details.documentNodeId);
delete data.documentNodeId;
}
if ('request' in details && details.request) {
request = details.request.url;
if (details.request.requestId && this.#options.requestIdResolver) {
const resolvedId = this.#options.requestIdResolver(details.request.requestId);
if (resolvedId) {
request = resolvedId;
const requestData = data.request;
delete requestData.requestId;
}
}
}
// These fields has no use for the MCP client (redundant or irrelevant).
delete data.errorType;
delete data.frameId;
affectedResources.push({
uid,
data: data,
request,
});
}
return affectedResources;
}
isValid() {
return this.#getTitle() !== undefined;
}
// Helper to extract title
#getTitle() {
const markdownDescription = this.#issue.getDescription();
const filename = markdownDescription?.file;
if (!filename) {
logger(`no description found for issue:` + this.#issue.code());
return undefined;
}
// We already have the description logic in #getDescription, but title extraction is separate
// We can reuse the logic or cache it.
// Ideally we should process markdown once.
const rawMarkdown = ISSUE_UTILS.getIssueDescription(filename);
if (!rawMarkdown) {
logger(`no markdown ${filename} found for issue:` + this.#issue.code());
return undefined;
}
try {
const processedMarkdown = DevTools.MarkdownIssueDescription.substitutePlaceholders(rawMarkdown, markdownDescription?.substitutions);
const markdownAst = DevTools.Marked.Marked.lexer(processedMarkdown);
const title = DevTools.MarkdownIssueDescription.findTitleFromMarkdownAst(markdownAst);
if (!title) {
logger('cannot read issue title from ' + filename);
return undefined;
}
return title;
}
catch {
logger('error parsing markdown for issue ' + this.#issue.code());
return undefined;
}
}
#getDescription() {
const markdownDescription = this.#issue.getDescription();
const filename = markdownDescription?.file;
if (!filename) {
return undefined;
}
const rawMarkdown = ISSUE_UTILS.getIssueDescription(filename);
if (!rawMarkdown) {
return undefined;
}
try {
return DevTools.MarkdownIssueDescription.substitutePlaceholders(rawMarkdown, markdownDescription?.substitutions);
}
catch {
return undefined;
}
}
}
function convertIssueConciseToString(issue) {
return `msgid=${issue.id} [issue] ${issue.title} (count: ${issue.count})`;
}
function convertIssueDetailedToString(issue) {
const result = [];
result.push(`ID: ${issue.id}`);
const bodyParts = [];
const description = issue.description;
let processedMarkdown = description?.trim();
// Remove heading in order not to conflict with the whole console message response markdown
if (processedMarkdown?.startsWith('# ')) {
processedMarkdown = processedMarkdown.substring(2).trimStart();
}
if (processedMarkdown) {
bodyParts.push(processedMarkdown);
}
else {
bodyParts.push(issue.title ?? 'Unknown Issue');
}
const links = issue.links;
if (links && links.length > 0) {
bodyParts.push('Learn more:');
for (const link of links) {
bodyParts.push(`[${link.linkTitle}](${link.link})`);
}
}
const affectedResources = issue.affectedResources;
if (affectedResources.length) {
bodyParts.push('### Affected resources');
bodyParts.push(...affectedResources.map(item => {
const details = [];
if (item.uid) {
details.push(`uid=${item.uid}`);
}
if (item.request) {
details.push((typeof item.request === 'number' ? `reqid=` : 'url=') +
item.request);
}
if (item.data) {
details.push(`data=${JSON.stringify(item.data)}`);
}
return details.join(' ');
}));
}
result.push(`Message: issue> ${bodyParts.join('\n')}`);
return result.join('\n');
}
@@ -0,0 +1,218 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
* */
import { isUtf8 } from 'node:buffer';
const BODY_CONTEXT_SIZE_LIMIT = 10000;
export class NetworkFormatter {
#request;
#options;
#requestBody;
#responseBody;
#requestBodyFilePath;
#responseBodyFilePath;
constructor(request, options) {
this.#request = request;
this.#options = options;
}
static async from(request, options) {
const instance = new NetworkFormatter(request, options);
if (options.fetchData) {
await instance.#loadDetailedData();
}
return instance;
}
async #loadDetailedData() {
// Load Request Body
if (this.#request.hasPostData()) {
let data;
try {
data =
this.#request.postData() ?? (await this.#request.fetchPostData());
}
catch {
// Ignore parsing errors
}
const requestBodyNotAvailableMessage = '<Request body not available anymore>';
if (this.#options.requestFilePath) {
if (!this.#options.saveFile) {
throw new Error('saveFile is not provided');
}
if (data) {
await this.#options.saveFile(Buffer.from(data), this.#options.requestFilePath);
this.#requestBodyFilePath = this.#options.requestFilePath;
}
else {
this.#requestBody = requestBodyNotAvailableMessage;
}
}
else {
if (data) {
this.#requestBody = getSizeLimitedString(data, BODY_CONTEXT_SIZE_LIMIT);
}
else {
this.#requestBody = requestBodyNotAvailableMessage;
}
}
}
// Load Response Body
const response = this.#request.response();
if (response) {
const responseBodyNotAvailableMessage = '<Response body not available anymore>';
if (this.#options.responseFilePath) {
try {
const buffer = await response.buffer();
if (!this.#options.saveFile) {
throw new Error('saveFile is not provided');
}
await this.#options.saveFile(buffer, this.#options.responseFilePath);
this.#responseBodyFilePath = this.#options.responseFilePath;
}
catch {
// Flatten error handling for buffer() failure and save failure
}
if (!this.#responseBodyFilePath) {
this.#responseBody = responseBodyNotAvailableMessage;
}
}
else {
this.#responseBody = await this.#getFormattedResponseBody(response, BODY_CONTEXT_SIZE_LIMIT);
}
}
}
toString() {
return convertNetworkRequestConciseToString(this.toJSON());
}
toStringDetailed() {
return converNetworkRequestDetailedToStringDetailed(this.toJSONDetailed());
}
toJSON() {
return {
requestId: this.#options.requestId,
method: this.#request.method(),
url: this.#request.url(),
status: this.#getStatusFromRequest(this.#request),
selectedInDevToolsUI: this.#options.selectedInDevToolsUI,
};
}
toJSONDetailed() {
const redirectChain = this.#request.redirectChain();
const formattedRedirectChain = redirectChain.reverse().map(request => {
const id = this.#options.requestIdResolver
? this.#options.requestIdResolver(request)
: undefined;
const formatter = new NetworkFormatter(request, {
requestId: id,
saveFile: this.#options.saveFile,
});
return formatter.toJSON();
});
return {
...this.toJSON(),
requestHeaders: this.#request.headers(),
requestBody: this.#requestBody,
requestBodyFilePath: this.#requestBodyFilePath,
responseHeaders: this.#request.response()?.headers(),
responseBody: this.#responseBody,
responseBodyFilePath: this.#responseBodyFilePath,
failure: this.#request.failure()?.errorText,
redirectChain: formattedRedirectChain.length
? formattedRedirectChain
: undefined,
};
}
#getStatusFromRequest(request) {
const httpResponse = request.response();
const failure = request.failure();
let status;
if (httpResponse) {
status = httpResponse.status().toString();
}
else if (failure) {
status = failure.errorText;
}
else {
status = 'pending';
}
return status;
}
async #getFormattedResponseBody(httpResponse, sizeLimit = BODY_CONTEXT_SIZE_LIMIT) {
try {
const responseBuffer = await httpResponse.buffer();
if (isUtf8(responseBuffer)) {
const responseAsTest = responseBuffer.toString('utf-8');
if (responseAsTest.length === 0) {
return '<empty response>';
}
return getSizeLimitedString(responseAsTest, sizeLimit);
}
return '<binary data>';
}
catch {
return '<not available anymore>';
}
}
}
function getSizeLimitedString(text, sizeLimit) {
if (text.length > sizeLimit) {
return text.substring(0, sizeLimit) + '... <truncated>';
}
return text;
}
function convertNetworkRequestConciseToString(data) {
// TODO truncate the URL
return `reqid=${data.requestId} ${data.method} ${data.url} [${data.status}]${data.selectedInDevToolsUI ? ` [selected in the DevTools Network panel]` : ''}`;
}
function formatHeadlers(headers) {
const response = [];
for (const [name, value] of Object.entries(headers)) {
response.push(`- ${name}:${value}`);
}
return response;
}
function converNetworkRequestDetailedToStringDetailed(data) {
const response = [];
response.push(`## Request ${data.url}`);
response.push(`Status: ${data.status}`);
response.push(`### Request Headers`);
for (const line of formatHeadlers(data.requestHeaders)) {
response.push(line);
}
if (data.requestBody) {
response.push(`### Request Body`);
response.push(data.requestBody);
}
else if (data.requestBodyFilePath) {
response.push(`### Request Body`);
response.push(`Saved to ${data.requestBodyFilePath}.`);
}
if (data.responseHeaders) {
response.push(`### Response Headers`);
for (const line of formatHeadlers(data.responseHeaders)) {
response.push(line);
}
}
if (data.responseBody) {
response.push(`### Response Body`);
response.push(data.responseBody);
}
else if (data.responseBodyFilePath) {
response.push(`### Response Body`);
response.push(`Saved to ${data.responseBodyFilePath}.`);
}
if (data.failure) {
response.push(`### Request failed with`);
response.push(data.failure);
}
const redirectChain = data.redirectChain;
if (redirectChain?.length) {
response.push(`### Redirect chain`);
let indent = 0;
for (const request of redirectChain.reverse()) {
response.push(`${' '.repeat(indent)}${convertNetworkRequestConciseToString(request)})}`);
indent++;
}
}
return response.join('\n');
}
@@ -0,0 +1,134 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export class SnapshotFormatter {
#snapshot;
constructor(snapshot) {
this.#snapshot = snapshot;
}
toString() {
const chunks = [];
const root = this.#snapshot.root;
// Top-level content of the snapshot.
if (this.#snapshot.verbose &&
this.#snapshot.hasSelectedElement &&
!this.#snapshot.selectedElementUid) {
chunks.push(`Note: there is a selected element in the DevTools Elements panel but it is not included into the current a11y tree snapshot.
Get a verbose snapshot to include all elements if you are interested in the selected element.\n\n`);
}
chunks.push(this.#formatNode(root, 0));
return chunks.join('');
}
toJSON() {
return this.#nodeToJSON(this.#snapshot.root);
}
#formatNode(node, depth = 0) {
const chunks = [];
const attributes = this.#getAttributes(node);
const line = ' '.repeat(depth * 2) +
attributes.join(' ') +
(node.id === this.#snapshot.selectedElementUid
? ' [selected in the DevTools Elements panel]'
: '') +
'\n';
chunks.push(line);
for (const child of node.children) {
chunks.push(this.#formatNode(child, depth + 1));
}
return chunks.join('');
}
#nodeToJSON(node) {
const rawAttrs = this.#getAttributesMap(node);
const children = node.children.map(child => this.#nodeToJSON(child));
const result = structuredClone(rawAttrs);
if (children.length > 0) {
result.children = children;
}
return result;
}
#getAttributes(serializedAXNodeRoot) {
const attributes = [`uid=${serializedAXNodeRoot.id}`];
if (serializedAXNodeRoot.role) {
attributes.push(serializedAXNodeRoot.role === 'none'
? 'ignored'
: serializedAXNodeRoot.role);
}
if (serializedAXNodeRoot.name) {
attributes.push(`"${serializedAXNodeRoot.name}"`);
}
const simpleAttrs = this.#getAttributesMap(serializedAXNodeRoot,
/* excludeSpecial */ true);
for (const attr of Object.keys(serializedAXNodeRoot).sort()) {
if (excludedAttributes.has(attr)) {
continue;
}
const mapped = booleanPropertyMap[attr];
if (mapped && simpleAttrs[mapped]) {
attributes.push(mapped);
}
const val = simpleAttrs[attr];
if (val === true) {
attributes.push(attr);
}
else if (typeof val === 'string' || typeof val === 'number') {
attributes.push(`${attr}="${val}"`);
}
}
return attributes;
}
#getAttributesMap(node, excludeSpecial = false) {
const result = {};
if (!excludeSpecial) {
result.id = node.id;
if (node.role) {
result.role = node.role;
}
if (node.name) {
result.name = node.name;
}
}
// Re-implementing the exact logic from original function for #getAttributes to be safe:
return {
...result,
...this.#extractedAttributes(node),
};
}
#extractedAttributes(node) {
const result = {};
for (const attr of Object.keys(node).sort()) {
if (excludedAttributes.has(attr)) {
continue;
}
const value = node[attr];
if (typeof value === 'boolean') {
if (booleanPropertyMap[attr]) {
result[booleanPropertyMap[attr]] = true;
}
if (value) {
result[attr] = true;
}
}
else if (typeof value === 'string' || typeof value === 'number') {
result[attr] = value;
}
}
return result;
}
}
const booleanPropertyMap = {
disabled: 'disableable',
expanded: 'expandable',
focused: 'focusable',
selected: 'selectable',
};
const excludedAttributes = new Set([
'id',
'role',
'name',
'elementHandle',
'children',
'backendNodeId',
'loaderId',
]);
+219
View File
@@ -0,0 +1,219 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { ensureBrowserConnected, ensureBrowserLaunched } from './browser.js';
import { loadIssueDescriptions } from './issue-descriptions.js';
import { logger } from './logger.js';
import { McpContext } from './McpContext.js';
import { McpResponse } from './McpResponse.js';
import { Mutex } from './Mutex.js';
import { SlimMcpResponse } from './SlimMcpResponse.js';
import { ClearcutLogger } from './telemetry/ClearcutLogger.js';
import { bucketizeLatency } from './telemetry/metricUtils.js';
import { McpServer, SetLevelRequestSchema, } from './third_party/index.js';
import { ToolCategory } from './tools/categories.js';
import { pageIdSchema } from './tools/ToolDefinition.js';
import { createTools } from './tools/tools.js';
import { VERSION } from './version.js';
export async function createMcpServer(serverArgs, options) {
let clearcutLogger;
if (serverArgs.usageStatistics) {
clearcutLogger = new ClearcutLogger({
logFile: serverArgs.logFile,
appVersion: VERSION,
clearcutEndpoint: serverArgs.clearcutEndpoint,
clearcutForceFlushIntervalMs: serverArgs.clearcutForceFlushIntervalMs,
clearcutIncludePidHeader: serverArgs.clearcutIncludePidHeader,
});
}
const server = new McpServer({
name: 'chrome_devtools',
title: 'Chrome DevTools MCP server',
version: VERSION,
}, { capabilities: { logging: {} } });
server.server.setRequestHandler(SetLevelRequestSchema, () => {
return {};
});
server.server.oninitialized = () => {
const clientName = server.server.getClientVersion()?.name;
if (clientName) {
clearcutLogger?.setClientName(clientName);
}
};
let context;
async function getContext() {
const chromeArgs = (serverArgs.chromeArg ?? []).map(String);
const ignoreDefaultChromeArgs = (serverArgs.ignoreDefaultChromeArg ?? []).map(String);
if (serverArgs.proxyServer) {
chromeArgs.push(`--proxy-server=${serverArgs.proxyServer}`);
}
const devtools = serverArgs.experimentalDevtools ?? false;
const browser = serverArgs.browserUrl || serverArgs.wsEndpoint || serverArgs.autoConnect
? await ensureBrowserConnected({
browserURL: serverArgs.browserUrl,
wsEndpoint: serverArgs.wsEndpoint,
wsHeaders: serverArgs.wsHeaders,
// Important: only pass channel, if autoConnect is true.
channel: serverArgs.autoConnect
? serverArgs.channel
: undefined,
userDataDir: serverArgs.userDataDir,
devtools,
})
: await ensureBrowserLaunched({
headless: serverArgs.headless,
executablePath: serverArgs.executablePath,
channel: serverArgs.channel,
isolated: serverArgs.isolated ?? false,
userDataDir: serverArgs.userDataDir,
logFile: options.logFile,
viewport: serverArgs.viewport,
chromeArgs,
ignoreDefaultChromeArgs,
acceptInsecureCerts: serverArgs.acceptInsecureCerts,
devtools,
enableExtensions: serverArgs.categoryExtensions,
viaCli: serverArgs.viaCli,
});
if (context?.browser !== browser) {
context = await McpContext.from(browser, logger, {
experimentalDevToolsDebugging: devtools,
experimentalIncludeAllPages: serverArgs.experimentalIncludeAllPages,
performanceCrux: serverArgs.performanceCrux,
});
}
return context;
}
const toolMutex = new Mutex();
function registerTool(tool) {
if (tool.annotations.category === ToolCategory.EMULATION &&
serverArgs.categoryEmulation === false) {
return;
}
if (tool.annotations.category === ToolCategory.PERFORMANCE &&
serverArgs.categoryPerformance === false) {
return;
}
if (tool.annotations.category === ToolCategory.NETWORK &&
serverArgs.categoryNetwork === false) {
return;
}
if (tool.annotations.category === ToolCategory.EXTENSIONS &&
!serverArgs.categoryExtensions) {
return;
}
if (tool.annotations.category === ToolCategory.IN_PAGE &&
!serverArgs.categoryInPageTools) {
return;
}
if (tool.annotations.conditions?.includes('computerVision') &&
!serverArgs.experimentalVision) {
return;
}
if (tool.annotations.conditions?.includes('experimentalInteropTools') &&
!serverArgs.experimentalInteropTools) {
return;
}
if (tool.annotations.conditions?.includes('screencast') &&
!serverArgs.experimentalScreencast) {
return;
}
const schema = 'pageScoped' in tool &&
tool.pageScoped &&
serverArgs.experimentalPageIdRouting &&
!serverArgs.slim
? { ...tool.schema, ...pageIdSchema }
: tool.schema;
server.registerTool(tool.name, {
description: tool.description,
inputSchema: schema,
annotations: tool.annotations,
}, async (params) => {
const guard = await toolMutex.acquire();
const startTime = Date.now();
let success = false;
try {
logger(`${tool.name} request: ${JSON.stringify(params, null, ' ')}`);
const context = await getContext();
logger(`${tool.name} context: resolved`);
await context.detectOpenDevToolsWindows();
const response = serverArgs.slim
? new SlimMcpResponse(serverArgs)
: new McpResponse(serverArgs);
if ('pageScoped' in tool && tool.pageScoped) {
const page = serverArgs.experimentalPageIdRouting &&
params.pageId &&
!serverArgs.slim
? context.getPageById(params.pageId)
: context.getSelectedMcpPage();
response.setPage(page);
await tool.handler({
params,
page,
}, response, context);
}
else {
await tool.handler(
// @ts-expect-error types do not match.
{
params,
}, response, context);
}
const { content, structuredContent } = await response.handle(tool.name, context);
const result = {
content,
};
success = true;
if (serverArgs.experimentalStructuredContent) {
result.structuredContent = structuredContent;
}
return result;
}
catch (err) {
logger(`${tool.name} error:`, err, err?.stack);
let errorText = err && 'message' in err ? err.message : String(err);
if ('cause' in err && err.cause) {
errorText += `\nCause: ${err.cause.message}`;
}
return {
content: [
{
type: 'text',
text: errorText,
},
],
isError: true,
};
}
finally {
void clearcutLogger?.logToolInvocation({
toolName: tool.name,
success,
latencyMs: bucketizeLatency(Date.now() - startTime),
});
guard.dispose();
}
});
}
const tools = createTools(serverArgs);
for (const tool of tools) {
registerTool(tool);
}
await loadIssueDescriptions();
return { server, clearcutLogger };
}
export const logDisclaimers = (args) => {
console.error(`chrome-devtools-mcp exposes content of the browser instance to the MCP clients allowing them to inspect,
debug, and modify any data in the browser or DevTools.
Avoid sharing sensitive or personal information that you do not want to share with MCP clients.`);
if (!args.slim && args.performanceCrux) {
console.error(`Performance tools may send trace URLs to the Google CrUX API to fetch real-user experience data. To disable, run with --no-performance-crux.`);
}
if (!args.slim && args.usageStatistics) {
console.error(`
Google collects usage statistics to improve Chrome DevTools MCP. To opt-out, run with --no-usage-statistics.
For more details, visit: https://github.com/ChromeDevTools/chrome-devtools-mcp#usage-statistics`);
}
};
+39
View File
@@ -0,0 +1,39 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
const DESCRIPTIONS_PATH = path.join(import.meta.dirname, 'third_party/issue-descriptions');
let issueDescriptions = {};
/**
* Reads all issue descriptions from the filesystem into memory.
*/
export async function loadIssueDescriptions() {
if (Object.keys(issueDescriptions).length > 0) {
return;
}
const files = await fs.promises.readdir(DESCRIPTIONS_PATH);
const descriptions = {};
for (const file of files) {
if (!file.endsWith('.md')) {
continue;
}
const content = await fs.promises.readFile(path.join(DESCRIPTIONS_PATH, file), 'utf-8');
descriptions[file] = content;
}
issueDescriptions = descriptions;
}
/**
* Gets an issue description from the in-memory cache.
* @param fileName The file name of the issue description.
* @returns The description of the issue, or null if it doesn't exist.
*/
export function getIssueDescription(fileName) {
return issueDescriptions[fileName] ?? null;
}
export const ISSUE_UTILS = {
loadIssueDescriptions,
getIssueDescription,
};
+36
View File
@@ -0,0 +1,36 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import { debug } from './third_party/index.js';
const mcpDebugNamespace = 'mcp:log';
const namespacesToEnable = [
mcpDebugNamespace,
...(process.env['DEBUG'] ? [process.env['DEBUG']] : []),
];
export function saveLogsToFile(fileName) {
// Enable overrides everything so we need to add them
debug.enable(namespacesToEnable.join(','));
const logFile = fs.createWriteStream(fileName, { flags: 'a+' });
debug.log = function (...chunks) {
logFile.write(`${chunks.join(' ')}\n`);
};
logFile.on('error', function (error) {
console.error(`Error when opening/writing to log file: ${error.message}`);
logFile.end();
process.exit(1);
});
return logFile;
}
export function flushLogs(logFile, timeoutMs = 2000) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(reject, timeoutMs);
logFile.end(() => {
clearTimeout(timeout);
resolve();
});
});
}
export const logger = debug(mcpDebugNamespace);
+7
View File
@@ -0,0 +1,7 @@
/**
* @license
* Copyright 2025 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
// polyfills are now bundled with all other dependencies
import './third_party/index.js';
+219
View File
@@ -0,0 +1,219 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import process from 'node:process';
import { DAEMON_CLIENT_NAME } from '../daemon/utils.js';
import { logger } from '../logger.js';
import { FilePersistence } from './persistence.js';
import { McpClient, WatchdogMessageType, OsType, } from './types.js';
import { WatchdogClient } from './WatchdogClient.js';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const PARAM_BLOCKLIST = new Set(['uid']);
const SUPPORTED_ZOD_TYPES = [
'ZodString',
'ZodNumber',
'ZodBoolean',
'ZodArray',
'ZodEnum',
];
function isZodType(type) {
return SUPPORTED_ZOD_TYPES.includes(type);
}
function getZodType(zodType) {
const def = zodType._def;
const typeName = def.typeName;
if (typeName === 'ZodOptional' ||
typeName === 'ZodDefault' ||
typeName === 'ZodNullable') {
return getZodType(def.innerType);
}
if (typeName === 'ZodEffects') {
return getZodType(def.schema);
}
if (isZodType(typeName)) {
return typeName;
}
throw new Error(`Unsupported zod type for tool parameter: ${typeName}`);
}
function transformName(zodType, name) {
if (zodType === 'ZodString') {
return `${name}_length`;
}
else if (zodType === 'ZodArray') {
return `${name}_count`;
}
else {
return name;
}
}
function transformValue(zodType, value) {
if (zodType === 'ZodString') {
return value.length;
}
else if (zodType === 'ZodArray') {
return value.length;
}
else {
return value;
}
}
function hasEquivalentType(zodType, value) {
if (zodType === 'ZodString') {
return typeof value === 'string';
}
else if (zodType === 'ZodArray') {
return Array.isArray(value);
}
else if (zodType === 'ZodNumber') {
return typeof value === 'number';
}
else if (zodType === 'ZodBoolean') {
return typeof value === 'boolean';
}
else if (zodType === 'ZodEnum') {
return (typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean');
}
else {
return false;
}
}
export function sanitizeParams(params, schema) {
const transformed = {};
for (const [name, value] of Object.entries(params)) {
if (PARAM_BLOCKLIST.has(name)) {
continue;
}
const zodType = getZodType(schema[name]);
if (!hasEquivalentType(zodType, value)) {
throw new Error(`parameter ${name} has type ${zodType} but value ${value} is not of equivalent type`);
}
const transformedName = transformName(zodType, name);
const transformedValue = transformValue(zodType, value);
transformed[transformedName] = transformedValue;
}
return transformed;
}
function detectOsType() {
switch (process.platform) {
case 'win32':
return OsType.OS_TYPE_WINDOWS;
case 'darwin':
return OsType.OS_TYPE_MACOS;
case 'linux':
return OsType.OS_TYPE_LINUX;
default:
return OsType.OS_TYPE_UNSPECIFIED;
}
}
export class ClearcutLogger {
#persistence;
#watchdog;
#mcpClient;
constructor(options) {
this.#persistence = options.persistence ?? new FilePersistence();
this.#watchdog =
options.watchdogClient ??
new WatchdogClient({
parentPid: process.pid,
appVersion: options.appVersion,
osType: detectOsType(),
logFile: options.logFile,
clearcutEndpoint: options.clearcutEndpoint,
clearcutForceFlushIntervalMs: options.clearcutForceFlushIntervalMs,
clearcutIncludePidHeader: options.clearcutIncludePidHeader,
});
this.#mcpClient = McpClient.MCP_CLIENT_UNSPECIFIED;
}
setClientName(clientName) {
const lowerName = clientName.toLowerCase();
if (lowerName.includes('claude')) {
this.#mcpClient = McpClient.MCP_CLIENT_CLAUDE_CODE;
}
else if (lowerName.includes('gemini')) {
this.#mcpClient = McpClient.MCP_CLIENT_GEMINI_CLI;
}
else if (clientName === DAEMON_CLIENT_NAME) {
this.#mcpClient = McpClient.MCP_CLIENT_DT_MCP_CLI;
}
else if (lowerName.includes('openclaw')) {
this.#mcpClient = McpClient.MCP_CLIENT_OPENCLAW;
}
else if (lowerName.includes('codex')) {
this.#mcpClient = McpClient.MCP_CLIENT_CODEX;
}
else if (lowerName.includes('antigravity')) {
this.#mcpClient = McpClient.MCP_CLIENT_ANTIGRAVITY;
}
else {
this.#mcpClient = McpClient.MCP_CLIENT_OTHER;
}
}
async logToolInvocation(args) {
this.#watchdog.send({
type: WatchdogMessageType.LOG_EVENT,
payload: {
mcp_client: this.#mcpClient,
tool_invocation: {
tool_name: args.toolName,
success: args.success,
latency_ms: args.latencyMs,
},
},
});
}
async logServerStart(flagUsage) {
this.#watchdog.send({
type: WatchdogMessageType.LOG_EVENT,
payload: {
mcp_client: this.#mcpClient,
server_start: {
flag_usage: flagUsage,
},
},
});
}
async logDailyActiveIfNeeded() {
try {
const state = await this.#persistence.loadState();
if (this.#shouldLogDailyActive(state)) {
let daysSince = -1;
if (state.lastActive) {
const lastActiveDate = new Date(state.lastActive);
const now = new Date();
const diffTime = Math.abs(now.getTime() - lastActiveDate.getTime());
daysSince = Math.ceil(diffTime / MS_PER_DAY);
}
this.#watchdog.send({
type: WatchdogMessageType.LOG_EVENT,
payload: {
mcp_client: this.#mcpClient,
daily_active: {
days_since_last_active: daysSince,
},
},
});
state.lastActive = new Date().toISOString();
await this.#persistence.saveState(state);
}
}
catch (err) {
logger('Error in logDailyActiveIfNeeded:', err);
}
}
#shouldLogDailyActive(state) {
if (!state.lastActive) {
return true;
}
const lastActiveDate = new Date(state.lastActive);
const now = new Date();
// Compare UTC dates
const isSameDay = lastActiveDate.getUTCFullYear() === now.getUTCFullYear() &&
lastActiveDate.getUTCMonth() === now.getUTCMonth() &&
lastActiveDate.getUTCDate() === now.getUTCDate();
return !isSameDay;
}
}
+60
View File
@@ -0,0 +1,60 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { logger } from '../logger.js';
export class WatchdogClient {
#childProcess;
constructor(config, options) {
const watchdogPath = fileURLToPath(new URL('./watchdog/main.js', import.meta.url));
const args = [
watchdogPath,
`--parent-pid=${config.parentPid}`,
`--app-version=${config.appVersion}`,
`--os-type=${config.osType}`,
];
if (config.logFile) {
args.push(`--log-file=${config.logFile}`);
}
if (config.clearcutEndpoint) {
args.push(`--clearcut-endpoint=${config.clearcutEndpoint}`);
}
if (config.clearcutForceFlushIntervalMs) {
args.push(`--clearcut-force-flush-interval-ms=${config.clearcutForceFlushIntervalMs}`);
}
if (config.clearcutIncludePidHeader) {
args.push('--clearcut-include-pid-header');
}
const spawner = options?.spawn ?? spawn;
this.#childProcess = spawner(process.execPath, args, {
stdio: ['pipe', 'ignore', 'ignore'],
detached: true,
});
this.#childProcess.unref();
this.#childProcess.on('error', err => {
logger('Watchdog process error:', err);
});
this.#childProcess.on('exit', (code, signal) => {
logger(`Watchdog exited with code ${code} and signal ${signal}`);
});
}
send(message) {
if (this.#childProcess.stdin &&
!this.#childProcess.stdin.destroyed &&
this.#childProcess.pid) {
try {
const line = JSON.stringify(message) + '\n';
this.#childProcess.stdin.write(line);
}
catch (err) {
logger('Failed to write to watchdog stdin', err);
}
}
else {
logger('Watchdog stdin not available, dropping message');
}
}
}
+45
View File
@@ -0,0 +1,45 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { toSnakeCase } from '../utils/string.js';
/**
* Computes telemetry flag usage from parsed arguments and CLI options.
*
* Iterates over the defined CLI options to construct a payload:
* - Flag names are converted to snake_case (e.g. `browserUrl` -> `browser_url`).
* - A flag is logged as `{flag_name}_present` if:
* - It has no default value, OR
* - The provided value differs from the default value.
* - Boolean flags are logged with their literal value.
* - String flags with defined `choices` (Enums) are logged as their uppercase value.
*/
export function computeFlagUsage(args, options) {
const usage = {};
for (const [flagName, config] of Object.entries(options)) {
const value = args[flagName];
const snakeCaseName = toSnakeCase(flagName);
// If there isn't a default value provided for the flag,
// we're going to log whether it's present on the args user
// provided or not. If there is a default value, we only log presence
// if the value differs from the default, implying explicit user intent.
if (!('default' in config) || value !== config.default) {
usage[`${snakeCaseName}_present`] = value !== undefined && value !== null;
}
if (config.type === 'boolean' && typeof value === 'boolean') {
// For boolean options, we're going to log the value directly.
usage[snakeCaseName] = value;
}
else if (config.type === 'string' &&
typeof value === 'string' &&
'choices' in config &&
config.choices) {
// For enums, log the value as uppercase
// We're going to have an enum for such flags with choices represented
// as an `enum` where the keys of the enum will map to the uppercase `choice`.
usage[snakeCaseName] = `${snakeCaseName}_${value}`.toUpperCase();
}
}
return usage;
}
+14
View File
@@ -0,0 +1,14 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const LATENCY_BUCKETS = [50, 100, 250, 500, 1000, 2500, 5000, 10000];
export function bucketizeLatency(latencyMs) {
for (const bucket of LATENCY_BUCKETS) {
if (latencyMs <= bucket) {
return bucket;
}
}
return LATENCY_BUCKETS[LATENCY_BUCKETS.length - 1];
}
+53
View File
@@ -0,0 +1,53 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { logger } from '../logger.js';
const STATE_FILE_NAME = 'telemetry_state.json';
function getDataFolder() {
const homedir = os.homedir();
const { env } = process;
const name = 'chrome-devtools-mcp';
if (process.platform === 'darwin') {
return path.join(homedir, 'Library', 'Application Support', name);
}
if (process.platform === 'win32') {
const localAppData = env.LOCALAPPDATA || path.join(homedir, 'AppData', 'Local');
return path.join(localAppData, name, 'Data');
}
return path.join(env.XDG_DATA_HOME || path.join(homedir, '.local', 'share'), name);
}
export class FilePersistence {
#dataFolder;
constructor(dataFolderOverride) {
this.#dataFolder = dataFolderOverride ?? getDataFolder();
}
async loadState() {
try {
const filePath = path.join(this.#dataFolder, STATE_FILE_NAME);
const content = await fs.readFile(filePath, 'utf-8');
return JSON.parse(content);
}
catch {
return {
lastActive: '',
};
}
}
async saveState(state) {
const filePath = path.join(this.#dataFolder, STATE_FILE_NAME);
try {
await fs.mkdir(this.#dataFolder, { recursive: true });
await fs.writeFile(filePath, JSON.stringify(state, null, 2), 'utf-8');
}
catch (error) {
// Ignore errors during state saving to avoid crashing the server
logger(`Failed to save telemetry state to ${filePath}:`, error);
}
}
}
+38
View File
@@ -0,0 +1,38 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Enums
export var OsType;
(function (OsType) {
OsType[OsType["OS_TYPE_UNSPECIFIED"] = 0] = "OS_TYPE_UNSPECIFIED";
OsType[OsType["OS_TYPE_WINDOWS"] = 1] = "OS_TYPE_WINDOWS";
OsType[OsType["OS_TYPE_MACOS"] = 2] = "OS_TYPE_MACOS";
OsType[OsType["OS_TYPE_LINUX"] = 3] = "OS_TYPE_LINUX";
})(OsType || (OsType = {}));
export var ChromeChannel;
(function (ChromeChannel) {
ChromeChannel[ChromeChannel["CHROME_CHANNEL_UNSPECIFIED"] = 0] = "CHROME_CHANNEL_UNSPECIFIED";
ChromeChannel[ChromeChannel["CHROME_CHANNEL_CANARY"] = 1] = "CHROME_CHANNEL_CANARY";
ChromeChannel[ChromeChannel["CHROME_CHANNEL_DEV"] = 2] = "CHROME_CHANNEL_DEV";
ChromeChannel[ChromeChannel["CHROME_CHANNEL_BETA"] = 3] = "CHROME_CHANNEL_BETA";
ChromeChannel[ChromeChannel["CHROME_CHANNEL_STABLE"] = 4] = "CHROME_CHANNEL_STABLE";
})(ChromeChannel || (ChromeChannel = {}));
export var McpClient;
(function (McpClient) {
McpClient[McpClient["MCP_CLIENT_UNSPECIFIED"] = 0] = "MCP_CLIENT_UNSPECIFIED";
McpClient[McpClient["MCP_CLIENT_CLAUDE_CODE"] = 1] = "MCP_CLIENT_CLAUDE_CODE";
McpClient[McpClient["MCP_CLIENT_GEMINI_CLI"] = 2] = "MCP_CLIENT_GEMINI_CLI";
McpClient[McpClient["MCP_CLIENT_DT_MCP_CLI"] = 4] = "MCP_CLIENT_DT_MCP_CLI";
McpClient[McpClient["MCP_CLIENT_OPENCLAW"] = 5] = "MCP_CLIENT_OPENCLAW";
McpClient[McpClient["MCP_CLIENT_CODEX"] = 6] = "MCP_CLIENT_CODEX";
McpClient[McpClient["MCP_CLIENT_ANTIGRAVITY"] = 7] = "MCP_CLIENT_ANTIGRAVITY";
McpClient[McpClient["MCP_CLIENT_OTHER"] = 3] = "MCP_CLIENT_OTHER";
})(McpClient || (McpClient = {}));
// IPC types for messages between the main process and the
// telemetry watchdog process.
export var WatchdogMessageType;
(function (WatchdogMessageType) {
WatchdogMessageType["LOG_EVENT"] = "log-event";
})(WatchdogMessageType || (WatchdogMessageType = {}));
@@ -0,0 +1,203 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import crypto from 'node:crypto';
import { logger } from '../../logger.js';
const MAX_BUFFER_SIZE = 1000;
const DEFAULT_CLEARCUT_ENDPOINT = 'https://play.googleapis.com/log?format=json_proto';
const DEFAULT_FLUSH_INTERVAL_MS = 15 * 60 * 1000;
const LOG_SOURCE = 2839;
const CLIENT_TYPE = 47;
const MIN_RATE_LIMIT_WAIT_MS = 30_000;
const REQUEST_TIMEOUT_MS = 30_000;
const SHUTDOWN_TIMEOUT_MS = 5_000;
const SESSION_ROTATION_INTERVAL_MS = 24 * 60 * 60 * 1000;
export class ClearcutSender {
#appVersion;
#osType;
#clearcutEndpoint;
#flushIntervalMs;
#includePidHeader;
#sessionId;
#sessionCreated;
#buffer = [];
#flushTimer = null;
#isFlushing = false;
#timerStarted = false;
constructor(config) {
this.#appVersion = config.appVersion;
this.#osType = config.osType;
this.#clearcutEndpoint =
config.clearcutEndpoint ?? DEFAULT_CLEARCUT_ENDPOINT;
this.#flushIntervalMs =
config.forceFlushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
this.#includePidHeader = config.includePidHeader ?? false;
this.#sessionId = crypto.randomUUID();
this.#sessionCreated = Date.now();
}
enqueueEvent(event) {
if (Date.now() - this.#sessionCreated > SESSION_ROTATION_INTERVAL_MS) {
this.#sessionId = crypto.randomUUID();
this.#sessionCreated = Date.now();
}
logger('Enqueing telemetry event', JSON.stringify(event, null, 2));
this.#addToBuffer({
...event,
session_id: this.#sessionId,
app_version: this.#appVersion,
os_type: this.#osType,
});
if (!this.#timerStarted) {
this.#timerStarted = true;
this.#scheduleFlush(this.#flushIntervalMs);
}
}
async sendShutdownEvent() {
if (this.#flushTimer) {
clearTimeout(this.#flushTimer);
this.#flushTimer = null;
}
const shutdownEvent = {
server_shutdown: {},
};
this.enqueueEvent(shutdownEvent);
try {
await Promise.race([
this.#finalFlush(),
new Promise(resolve => setTimeout(resolve, SHUTDOWN_TIMEOUT_MS)),
]);
logger('Final flush completed');
}
catch (error) {
logger('Final flush failed:', error);
}
}
async #flush() {
if (this.#isFlushing) {
return;
}
if (this.#buffer.length === 0) {
this.#scheduleFlush(this.#flushIntervalMs);
return;
}
this.#isFlushing = true;
let nextDelayMs = this.#flushIntervalMs;
// Optimistically remove events from buffer before sending.
// This prevents race conditions where a simultaneous #finalFlush would include these same events.
const eventsToSend = [...this.#buffer];
this.#buffer = [];
try {
const result = await this.#sendBatch(eventsToSend);
if (result.success) {
if (result.nextRequestWaitMs !== undefined) {
nextDelayMs = Math.max(result.nextRequestWaitMs, MIN_RATE_LIMIT_WAIT_MS);
}
}
else if (result.isPermanentError) {
logger('Permanent error, dropped batch of', eventsToSend.length, 'events');
}
else {
// Transient error: Requeue events at the front of the buffer
// to maintain order and retry them later.
this.#buffer = [...eventsToSend, ...this.#buffer];
}
}
catch (error) {
// Safety catch for unexpected errors, requeue events
this.#buffer = [...eventsToSend, ...this.#buffer];
logger('Flush failed unexpectedly:', error);
}
finally {
this.#isFlushing = false;
this.#scheduleFlush(nextDelayMs);
}
}
#addToBuffer(event) {
if (this.#buffer.length >= MAX_BUFFER_SIZE) {
this.#buffer.shift();
logger('Telemetry buffer overflow: dropped oldest event');
}
this.#buffer.push({
event,
timestamp: Date.now(),
});
}
#scheduleFlush(delayMs) {
logger(`Scheduling flush in ${delayMs}`);
if (this.#flushTimer) {
clearTimeout(this.#flushTimer);
}
this.#flushTimer = setTimeout(() => {
this.#flush().catch(err => {
logger('Flush error:', err);
});
}, delayMs);
}
async #sendBatch(events) {
logger(`Sending batch of ${events.length}`);
const requestBody = {
log_source: LOG_SOURCE,
request_time_ms: Date.now().toString(),
client_info: {
client_type: CLIENT_TYPE,
},
log_event: events.map(({ event, timestamp }) => ({
event_time_ms: timestamp.toString(),
source_extension_json: JSON.stringify(event),
})),
};
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(this.#clearcutEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Used in E2E tests to confirm that the watchdog process is killed
...(this.#includePidHeader
? { 'X-Watchdog-Pid': process.pid.toString() }
: {}),
},
body: JSON.stringify(requestBody),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (response.ok) {
const data = (await response.json());
return {
success: true,
nextRequestWaitMs: data.next_request_wait_millis,
};
}
const status = response.status;
if (status >= 500 || status === 429) {
return { success: false };
}
logger('Telemetry permanent error:', status);
return { success: false, isPermanentError: true };
}
catch {
clearTimeout(timeoutId);
return { success: false };
}
}
async #finalFlush() {
if (this.#buffer.length === 0) {
return;
}
const eventsToSend = [...this.#buffer];
await this.#sendBatch(eventsToSend);
}
stopForTesting() {
if (this.#flushTimer) {
clearTimeout(this.#flushTimer);
this.#flushTimer = null;
}
this.#timerStarted = false;
}
get bufferSizeForTesting() {
return this.#buffer.length;
}
}
+127
View File
@@ -0,0 +1,127 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import process from 'node:process';
import readline from 'node:readline';
import { parseArgs } from 'node:util';
import { logger, flushLogs, saveLogsToFile } from '../../logger.js';
import { WatchdogMessageType } from '../types.js';
import { ClearcutSender } from './ClearcutSender.js';
function parseWatchdogArgs() {
const { values } = parseArgs({
options: {
'parent-pid': { type: 'string' },
'app-version': { type: 'string' },
'os-type': { type: 'string' },
'log-file': { type: 'string' },
'clearcut-endpoint': { type: 'string' },
'clearcut-force-flush-interval-ms': { type: 'string' },
'clearcut-include-pid-header': { type: 'boolean' },
},
strict: true,
});
// Verify required arguments
const parentPid = parseInt(values['parent-pid'] ?? '', 10);
const appVersion = values['app-version'];
const osType = parseInt(values['os-type'] ?? '', 10);
if (isNaN(parentPid) || !appVersion || isNaN(osType)) {
console.error('Invalid arguments provided for watchdog process: ', JSON.stringify({ parentPid, appVersion, osType }));
process.exit(1);
}
// Parse Optional Arguments
const logFile = values['log-file'];
const clearcutEndpoint = values['clearcut-endpoint'];
const clearcutIncludePidHeader = values['clearcut-include-pid-header'];
let clearcutForceFlushIntervalMs;
if (values['clearcut-force-flush-interval-ms']) {
const parsed = parseInt(values['clearcut-force-flush-interval-ms'], 10);
if (!isNaN(parsed)) {
clearcutForceFlushIntervalMs = parsed;
}
}
return {
parentPid,
appVersion,
osType,
logFile,
clearcutEndpoint,
clearcutForceFlushIntervalMs,
clearcutIncludePidHeader,
};
}
function main() {
const { parentPid, appVersion, osType, logFile, clearcutEndpoint, clearcutForceFlushIntervalMs, clearcutIncludePidHeader, } = parseWatchdogArgs();
let logStream;
if (logFile) {
logStream = saveLogsToFile(logFile);
}
const exit = (code) => {
if (!logStream) {
process.exit(code);
}
void flushLogs(logStream).finally(() => {
process.exit(code);
});
};
logger('Watchdog started', JSON.stringify({
pid: process.pid,
parentPid,
version: appVersion,
osType,
}, null, 2));
const sender = new ClearcutSender({
appVersion,
osType: osType,
clearcutEndpoint,
forceFlushIntervalMs: clearcutForceFlushIntervalMs,
includePidHeader: clearcutIncludePidHeader,
});
let isShuttingDown = false;
function onParentDeath(reason) {
if (isShuttingDown) {
return;
}
isShuttingDown = true;
logger(`Parent death detected (${reason}). Sending shutdown event...`);
sender
.sendShutdownEvent()
.then(() => {
logger('Shutdown event sent. Exiting.');
exit(0);
})
.catch(err => {
logger('Failed to send shutdown event', err);
exit(1);
});
}
process.stdin.on('end', () => onParentDeath('stdin end'));
process.stdin.on('close', () => onParentDeath('stdin close'));
process.on('disconnect', () => onParentDeath('ipc disconnect'));
const rl = readline.createInterface({
input: process.stdin,
terminal: false,
});
rl.on('line', line => {
try {
if (!line.trim()) {
return;
}
const msg = JSON.parse(line);
if (msg.type === WatchdogMessageType.LOG_EVENT && msg.payload) {
sender.enqueueEvent(msg.payload);
}
}
catch (err) {
logger('Failed to parse IPC message', err);
}
});
}
try {
main();
}
catch (err) {
console.error('Watchdog fatal error:', err);
process.exit(1);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,9 @@
{
"@modelcontextprotocol/sdk": "1.28.0",
"chrome-devtools-frontend": "1.0.1602348",
"core-js": "3.49.0",
"debug": "4.4.3",
"lighthouse": "13.0.3",
"yargs": "18.0.0",
"puppeteer-core": "24.40.0"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,4 @@
# An iframe navigation to a document with a Cross-Origin Opener Policy was blocked
A document with a Cross-Origin Opener Policy (COOP) was blocked from loading in an iframe, because the iframe specifies a sandbox attribute.
This protects COOP-enabled documents from inheriting properties from its opener.
@@ -0,0 +1,8 @@
# Specify a more permissive Cross-Origin Resource Policy to prevent a resource from being blocked
Your site tries to access an external resource that only allows same-origin usage.
This behavior prevents a document from loading any non-same-origin resources which dont explicitly grant permission to be loaded.
To solve this, add the following to the resources HTML response header:
* `Cross-Origin-Resource-Policy: same-site` if the resource and your site are served from the same site.
* `Cross-Origin-Resource-Policy: cross-origin` if the resource is served from another location than your website. ⚠️If you set this header, any website can embed this resource.
@@ -0,0 +1,18 @@
# Specify a Cross-Origin Resource Policy to prevent a resource from being blocked
Because your site has the Cross-Origin Embedder Policy (COEP) enabled, each
resource must specify a suitable Cross-Origin Resource Policy (CORP). This
behavior prevents a document from loading cross-origin resources which dont
explicitly grant permission to be loaded.
To solve this, add the following to the resource response header:
* `Cross-Origin-Resource-Policy: same-site` if the resource and your site are
served from the same site.
* `Cross-Origin-Resource-Policy: cross-origin` if the resource is served from
another location than your website. ⚠️If you set this header, any website can
embed this resource.
Alternatively, the document can use the variant: `Cross-Origin-Embedder-Policy:
credentialless` instead of `require-corp`. It allows loading the resource,
despite the missing CORP header, at the cost of requesting it without
credentials like Cookies.
@@ -0,0 +1,7 @@
# Specify a more permissive Cross-Origin Resource Policy to prevent a resource from being blocked
Your site tries to access an external resource that only allows same-site usage.
This behavior prevents a document from loading any non-same-site resources which dont explicitly grant permission to be loaded.
To solve this, add the following to the resources HTML response header: `Cross-Origin-Resource-Policy: cross-origin`
⚠️If you set this header, any website can embed this resource.
@@ -0,0 +1,10 @@
# Specify a Cross-Origin Embedder Policy to prevent this frame from being blocked
Because your site has the Cross-Origin Embedder Policy (COEP) enabled, each
embedded iframe must also specify this policy. This behavior protects private
data from being exposed to untrusted third party sites.
To solve this, add one of following to the embedded frames HTML response
header:
* `Cross-Origin-Embedder-Policy: require-corp`
* `Cross-Origin-Embedder-Policy: credentialless` (Chrome > 96)
@@ -0,0 +1,5 @@
# Page layout may be unexpected due to Quirks Mode
One or more documents in this page is in Quirks Mode, which will render the affected document(s) with quirks incompatible with the current HTML and CSS specifications.
Quirks Mode exists mostly due to historical reasons. If this is not intentional, you can [add or modify the DOCTYPE to be `<!DOCTYPE html>`](issueQuirksModeDoctype) to render the page in No Quirks Mode.
@@ -0,0 +1,5 @@
# Ensure cookie attribute values dont exceed 1024 characters
Cookie attribute values exceeding 1024 characters in size will result in the attribute being ignored. This could lead to unexpected behavior since the cookie will be processed as if the offending attribute / attribute value pair were not present.
Resolve this issue by ensuring that cookie attribute values dont exceed 1024 characters.
@@ -0,0 +1,5 @@
# Users may have difficulties reading text content due to insufficient color contrast
Low-contrast text is difficult or impossible for users to read. A [minimum contrast ratio (AA) of 4.5](issuesContrastWCAG21AA) is recommended for all text. Since font size and weight affect color perception, an exception is made for very large or bold text — in this case, a contrast ratio of 3.0 is allowed. The [enhanced conformance level (AAA)](issuesContrastWCAG21AAA) requires the contrast ratio to be above 7.0 for regular text and 4.5 for large text.
Update colors or change the font size or weight to achieve sufficient contrast. You can use the [“Suggest color” feature](issuesContrastSuggestColor) in the DevTools color picker to automatically select a better text color.
@@ -0,0 +1,8 @@
# Migrate entirely to HTTPS to have cookies sent to same-site subresources
A cookie was not sent to {PLACEHOLDER_destination} origin from {PLACEHOLDER_origin} context.
Because this cookie would have been sent across schemes on the same site, it was not sent.
This behavior enhances the `SameSite` attributes protection of user data from request forgery by network attackers.
Resolve this issue by migrating your site (as defined by the eTLD+1) entirely to HTTPS.
It is also recommended to mark the cookie with the `Secure` attribute if that is not already the case.
@@ -0,0 +1,8 @@
# Migrate entirely to HTTPS to allow cookies to be set by same-site subresources
A cookie was not set by {PLACEHOLDER_origin} origin in {PLACEHOLDER_destination} context.
Because this cookie would have been set across schemes on the same site, it was blocked.
This behavior enhances the `SameSite` attributes protection of user data from request forgery by network attackers.
Resolve this issue by migrating your site (as defined by the eTLD+1) entirely to HTTPS.
It is also recommended to mark the cookie with the `Secure` attribute if that is not already the case.

Some files were not shown because too many files have changed in this diff Show More