First Push
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>控制面板 - Cal Display</title>
|
||||
<link rel="stylesheet" href="/static/css/control.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="app">
|
||||
<header>
|
||||
<h1>Cal Display 控制面板 <span id="current-user-badge" style="font-size:13px;font-weight:400;color:var(--text-dim);margin-left:8px;"></span></h1>
|
||||
<nav id="tabs">
|
||||
<button class="tab active" data-tab="calendar">日历</button>
|
||||
<button class="tab" data-tab="weather">天气</button>
|
||||
<button class="tab" data-tab="alarms">闹钟</button>
|
||||
<button class="tab" data-tab="pomodoro">番茄钟</button>
|
||||
<button class="tab" data-tab="users" id="users-tab" style="display:none;">用户管理</button>
|
||||
<button class="tab" data-tab="settings">设置</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- Calendar Tab -->
|
||||
<section id="tab-calendar" class="tab-content active">
|
||||
<!-- Groups -->
|
||||
<div class="section">
|
||||
<h3>日历分组</h3>
|
||||
<div id="group-list"></div>
|
||||
<form id="group-form" class="inline-form">
|
||||
<input type="text" id="group-name" placeholder="分组名称" required>
|
||||
<input type="color" id="group-color" value="#4A90D9">
|
||||
<button type="submit">添加分组</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Upload ICS -->
|
||||
<div class="section">
|
||||
<h3>上传 ICS 文件</h3>
|
||||
<form id="upload-form" class="inline-form">
|
||||
<select id="upload-group"></select>
|
||||
<input type="text" id="upload-name" placeholder="来源名称" required>
|
||||
<input type="file" id="upload-file" accept=".ics" required>
|
||||
<button type="submit">上传</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Subscribe ICS -->
|
||||
<div class="section">
|
||||
<h3>订阅 ICS 链接</h3>
|
||||
<form id="subscribe-form" class="inline-form">
|
||||
<select id="subscribe-group"></select>
|
||||
<input type="text" id="subscribe-name" placeholder="来源名称" required>
|
||||
<input type="url" id="subscribe-url" placeholder="https://example.com/calendar.ics" required>
|
||||
<button type="submit">订阅</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Reminders -->
|
||||
<div class="section">
|
||||
<h3>提醒设置</h3>
|
||||
<form id="reminder-form" class="inline-form">
|
||||
<select id="reminder-group"></select>
|
||||
<input type="number" id="reminder-minutes" value="15" min="0" max="1440" style="width:70px">
|
||||
<label>分钟前</label>
|
||||
<label><input type="checkbox" id="reminder-enabled" checked> 启用</label>
|
||||
<button type="submit">保存</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Manual Add Event -->
|
||||
<div class="section">
|
||||
<h3>手动添加日程</h3>
|
||||
<form id="event-form" class="inline-form">
|
||||
<select id="event-group"></select>
|
||||
<input type="text" id="event-summary" placeholder="日程标题" required>
|
||||
<input type="datetime-local" id="event-dtstart" required>
|
||||
<input type="datetime-local" id="event-dtend">
|
||||
<input type="text" id="event-location" placeholder="地点 (可选)">
|
||||
<button type="submit">添加</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Group Events -->
|
||||
<div class="section">
|
||||
<h3>分组日程管理</h3>
|
||||
<div class="inline-form" style="margin-bottom:12px;">
|
||||
<select id="browse-group"></select>
|
||||
<button type="button" id="browse-events-btn">查看日程</button>
|
||||
</div>
|
||||
<div id="group-events-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Weather Tab -->
|
||||
<section id="tab-weather" class="tab-content">
|
||||
<!-- JWT Key Management -->
|
||||
<div class="section">
|
||||
<h3>JWT 密钥管理(和风天气)</h3>
|
||||
<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px;">
|
||||
生成本地 Ed25519 密钥对,将公钥复制到
|
||||
<a href="https://console.qweather.com" target="_blank" style="color:var(--accent);">和风天气控制台</a>
|
||||
→ 凭据管理 → 添加凭据。
|
||||
</p>
|
||||
<div class="form-row">
|
||||
<label>公钥(复制到和风控制台)</label>
|
||||
<textarea id="weather-public-key" rows="4" readonly
|
||||
style="width:100%;font-family:monospace;font-size:12px;resize:none;"></textarea>
|
||||
</div>
|
||||
<button type="button" id="generate-keys-btn">生成密钥对</button>
|
||||
<span id="key-gen-status" style="margin-left:10px;font-size:13px;color:var(--text-dim);"></span>
|
||||
</div>
|
||||
|
||||
<!-- City Search -->
|
||||
<div class="section">
|
||||
<h3>城市搜索</h3>
|
||||
<div class="inline-form">
|
||||
<input type="text" id="weather-city-search" placeholder="输入城市名搜索…" style="min-width:200px;">
|
||||
<button type="button" id="city-search-btn">搜索</button>
|
||||
</div>
|
||||
<div id="city-results" style="margin-top:8px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- QWeather Config -->
|
||||
<div class="section">
|
||||
<h3>和风天气配置</h3>
|
||||
<form id="weather-form">
|
||||
<!-- JWT fields(推荐;未填时自动回退到 API Key) -->
|
||||
<div id="jwt-fields">
|
||||
<div class="form-row">
|
||||
<label>Credential ID (kid)</label>
|
||||
<input type="text" id="weather-kid" placeholder="从和风控制台获取">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Project ID (sub)</label>
|
||||
<input type="text" id="weather-sub" placeholder="从和风控制台获取">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>专属 API Host(必填!)</label>
|
||||
<input type="text" id="weather-host" placeholder="如 abc1234.def.qweatherapi.com">
|
||||
<div style="font-size:12px;color:var(--accent-red);margin-top:4px;">
|
||||
⚠️ 共享域名已弃用!登录 <a href="https://console.qweather.com" target="_blank" style="color:var(--accent);">和风控制台</a>
|
||||
→ 产品管理 → 你的项目 → API Host,复制完整域名填入此处
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- API Key(可选):和风旧版 API Key,JWT 配置完整时自动优先 JWT -->
|
||||
<div id="legacy-key-field">
|
||||
<div class="form-row">
|
||||
<label>API Key(可选)</label>
|
||||
<input type="text" id="weather-apikey" placeholder="输入和风 API Key(未配置 JWT 时使用)">
|
||||
</div>
|
||||
</div>
|
||||
<!-- Common fields -->
|
||||
<div class="form-row">
|
||||
<label>城市名称</label>
|
||||
<input type="text" id="weather-city" placeholder="Beijing">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>纬度</label>
|
||||
<input type="number" id="weather-lat" step="0.0001" placeholder="39.9042">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>经度</label>
|
||||
<input type="number" id="weather-lon" step="0.0001" placeholder="116.4074">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>单位</label>
|
||||
<select id="weather-units">
|
||||
<option value="metric">摄氏度 (metric)</option>
|
||||
<option value="imperial">华氏度 (imperial)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit">保存配置</button>
|
||||
<button type="button" id="weather-refresh" class="btn-secondary">立即刷新天气</button>
|
||||
<span id="weather-save-status" style="margin-left:10px;font-size:13px;color:var(--text-dim);"></span>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Current weather preview -->
|
||||
<div class="section">
|
||||
<h3>当前天气预览</h3>
|
||||
<div id="weather-preview" style="font-size:14px;color:var(--text-dim);">加载中...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Alarms Tab -->
|
||||
<section id="tab-alarms" class="tab-content">
|
||||
<!-- Alarms -->
|
||||
<div class="section">
|
||||
<h3>闹钟列表</h3>
|
||||
<div id="alarm-list"></div>
|
||||
<form id="alarm-form" class="inline-form">
|
||||
<input type="text" id="alarm-label" placeholder="标签">
|
||||
<input type="time" id="alarm-time" required>
|
||||
<select id="alarm-sound">
|
||||
<option value="default">默认铃声</option>
|
||||
</select>
|
||||
<label><input type="checkbox" id="alarm-once"> 仅一次</label>
|
||||
<div class="day-picker" id="alarm-days-picker">
|
||||
<label><input type="checkbox" value="1" checked>一</label>
|
||||
<label><input type="checkbox" value="2" checked>二</label>
|
||||
<label><input type="checkbox" value="3" checked>三</label>
|
||||
<label><input type="checkbox" value="4" checked>四</label>
|
||||
<label><input type="checkbox" value="5" checked>五</label>
|
||||
<label><input type="checkbox" value="6" checked>六</label>
|
||||
<label><input type="checkbox" value="7" checked>日</label>
|
||||
</div>
|
||||
<button type="submit">添加闹钟</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Ringtone Upload -->
|
||||
<div class="section">
|
||||
<h3>自定义铃声</h3>
|
||||
<form id="ringtone-form" class="inline-form">
|
||||
<input type="file" id="ringtone-file" accept=".mp3,.wav,.ogg" required>
|
||||
<button type="submit">上传铃声</button>
|
||||
</form>
|
||||
<div id="ringtone-list" style="margin-top:8px;font-size:13px;color:var(--text-dim);"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Pomodoro Tab -->
|
||||
<section id="tab-pomodoro" class="tab-content">
|
||||
<div class="section">
|
||||
<h3>番茄钟设置</h3>
|
||||
<div id="pomodoro-status" style="margin-bottom:12px;font-size:14px;color:var(--text-dim);"></div>
|
||||
<form id="pomodoro-form" class="inline-form">
|
||||
<label>工作 <input type="number" id="pomodoro-work" value="25" min="1" max="120" style="width:70px"> 分钟</label>
|
||||
<label>休息 <input type="number" id="pomodoro-break" value="5" min="1" max="60" style="width:70px"> 分钟</label>
|
||||
<label>长休息 <input type="number" id="pomodoro-long-break" value="15" min="1" max="120" style="width:70px"> 分钟</label>
|
||||
<label>每 <input type="number" id="pomodoro-cycles-before-long" value="4" min="1" max="20" style="width:60px"> 组后长休息</label>
|
||||
<label><input type="text" id="pomodoro-work-label" placeholder="工作中" style="width:80px"> 工作标签</label>
|
||||
<label><input type="text" id="pomodoro-break-label" placeholder="休息中" style="width:80px"> 休息标签</label>
|
||||
<label><input type="checkbox" id="pomodoro-display-controls" checked> 允许显示端控制</label>
|
||||
<button type="submit">保存设置</button>
|
||||
</form>
|
||||
<div style="margin-top:12px;display:flex;gap:8px;">
|
||||
<button type="button" id="pomodoro-start-btn">开始</button>
|
||||
<button type="button" id="pomodoro-pause-btn" class="btn-secondary">暂停</button>
|
||||
<button type="button" id="pomodoro-skip-btn" class="btn-secondary">跳过</button>
|
||||
<button type="button" id="pomodoro-stop-btn" class="btn-danger">停止</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Users Tab (admin only) -->
|
||||
<section id="tab-users" class="tab-content">
|
||||
<div class="section">
|
||||
<h3>用户管理</h3>
|
||||
<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px;">
|
||||
仅管理员可用。注册/编辑/删除用户均为高危操作,每次操作前需二次验证管理员身份。
|
||||
新注册用户的全部数据相互隔离,且不拥有用户管理权限。
|
||||
</p>
|
||||
<div style="margin-bottom:12px;display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<button type="button" id="user-add-admin" class="btn-primary">👨💼 管理员代注册</button>
|
||||
<button type="button" id="user-add-temp" class="btn-secondary">🕐 创建临时账户(用户填写信息模式)</button>
|
||||
<button type="button" id="user-refresh-btn" class="btn-secondary">刷新列表</button>
|
||||
</div>
|
||||
<div id="pending-requests" style="margin-bottom:16px;"></div>
|
||||
<div id="user-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<section id="tab-settings" class="tab-content">
|
||||
<div class="section">
|
||||
<h3>主题模式</h3>
|
||||
<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px;">
|
||||
跟随账户独立设置,显示端实时同步生效。
|
||||
<strong>自动</strong> 模式按你天气设置中的城市经纬度计算日出日落,白天浅色、夜晚深色。
|
||||
</p>
|
||||
<div class="inline-form">
|
||||
<select id="theme-mode">
|
||||
<option value="dark">深色模式</option>
|
||||
<option value="light">浅色模式</option>
|
||||
<option value="auto">自动(日出日落)</option>
|
||||
</select>
|
||||
<button type="button" id="theme-save-btn">保存主题</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>显示端模式</h3> <form id="mode-form" class="inline-form">
|
||||
<select id="display-mode">
|
||||
<option value="default">默认页面</option>
|
||||
<option value="custom">自定义 HTML</option>
|
||||
</select>
|
||||
<button type="submit">切换</button>
|
||||
</form>
|
||||
<div id="html-editor-wrap" style="margin-top:12px;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
|
||||
<label style="font-size:13px;color:var(--text-dim);">当前编辑: <strong id="html-editor-label">HTML 0</strong></label>
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button type="button" class="small-btn html-tab-btn active" data-idx="0">HTML 0</button>
|
||||
<button type="button" class="small-btn html-tab-btn" data-idx="1">HTML 1</button>
|
||||
<button type="button" class="small-btn html-tab-btn" data-idx="2">HTML 2</button>
|
||||
<button type="button" class="small-btn html-tab-btn" data-idx="3">HTML 3</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea id="custom-html-editor" rows="8" style="width:100%;padding:10px;border:1px solid var(--border);border-radius:6px;font-family:monospace;font-size:13px;resize:vertical;" placeholder="在此输入 HTML..."></textarea>
|
||||
<div style="margin-top:8px;display:flex;gap:8px;">
|
||||
<button type="button" id="save-html-btn">保存当前模板</button>
|
||||
<button type="button" id="apply-html-btn" class="btn-secondary">使用当前 HTML</button>
|
||||
<button type="button" id="refresh-display-btn" class="btn-secondary">强制刷新显示端</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:8px;display:flex;gap:8px;">
|
||||
<button type="button" id="kick-all-btn" class="btn-danger">踢掉所有显示端</button>
|
||||
</div>
|
||||
<div id="mode-msg" style="margin-top:8px;font-size:13px;color:var(--text-dim);"></div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h3>时区设置</h3>
|
||||
<div style="font-size:13px;color:var(--text-dim);margin-bottom:10px;">
|
||||
所有本地时间将根据此设置显示。使用 <a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones" target="_blank" style="color:var(--accent);">IANA 时区名称</a> 或 UTC+X 格式。
|
||||
</div>
|
||||
<form id="timezone-form" class="inline-form">
|
||||
<input type="text" id="timezone-input" value="Asia/Shanghai" style="min-width:220px;" placeholder="Asia/Shanghai">
|
||||
<button type="submit">保存时区</button>
|
||||
<span id="timezone-msg" style="margin-left:10px;font-size:13px;color:var(--text-dim);"></span>
|
||||
</form>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h3>手动推送提醒</h3>
|
||||
<form id="notify-form" class="inline-form">
|
||||
<input type="text" id="notify-title" placeholder="标题" value="提醒" required>
|
||||
<input type="text" id="notify-message" placeholder="内容" required>
|
||||
<label><input type="checkbox" id="notify-sound" checked> 播放声音</label>
|
||||
<button type="submit">推送</button>
|
||||
</form>
|
||||
<div id="notify-msg" style="margin-top:8px;font-size:13px;color:var(--text-dim);"></div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h3>双因素认证 (MFA)</h3>
|
||||
<div id="mfa-status" style="font-size:13px;color:var(--text-dim);margin-bottom:12px;">加载中...</div>
|
||||
|
||||
<!-- Passkey -->
|
||||
<div style="margin-bottom:16px;padding:14px;border:1px solid var(--border);border-radius:8px;">
|
||||
<h4 style="margin-bottom:8px;font-size:14px;">🔑 Passkey (设备密钥)</h4>
|
||||
<p style="font-size:13px;color:var(--text-dim);margin-bottom:8px;">
|
||||
使用指纹、面容或安全密钥快速登录。
|
||||
</p>
|
||||
<button type="button" id="mfa-register-passkey" class="btn-secondary">注册 Passkey</button>
|
||||
<button type="button" id="mfa-remove-passkey" class="btn-danger" style="display:none;">移除 Passkey</button>
|
||||
<div id="mfa-passkey-status" style="font-size:13px;color:var(--text-dim);margin-top:6px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- TOTP -->
|
||||
<div style="margin-bottom:16px;padding:14px;border:1px solid var(--border);border-radius:8px;">
|
||||
<h4 style="margin-bottom:8px;font-size:14px;">📱 TOTP (身份验证器)</h4>
|
||||
<p style="font-size:13px;color:var(--text-dim);margin-bottom:8px;">
|
||||
使用 Google Authenticator、Microsoft Authenticator 等应用。
|
||||
</p>
|
||||
<button type="button" id="mfa-setup-totp" class="btn-secondary">设置 TOTP</button>
|
||||
<button type="button" id="mfa-remove-totp" class="btn-danger" style="display:none;">停用 TOTP</button>
|
||||
<div id="mfa-totp-area" style="display:none;margin-top:12px;padding:12px;background:#fafafa;border-radius:8px;">
|
||||
<div id="mfa-totp-qr" style="text-align:center;"></div>
|
||||
<div id="mfa-totp-secret" style="font-family:monospace;font-size:12px;text-align:center;margin:8px 0;"></div>
|
||||
<div class="inline-form">
|
||||
<input type="text" id="mfa-totp-code" placeholder="6 位验证码" maxlength="6" inputmode="numeric" style="width:140px;">
|
||||
<button type="button" id="mfa-totp-verify-btn">验证</button>
|
||||
</div>
|
||||
<div id="mfa-totp-msg" style="font-size:13px;margin-top:6px;"></div>
|
||||
</div>
|
||||
<div id="mfa-totp-status" style="font-size:13px;color:var(--text-dim);margin-top:6px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Recovery codes -->
|
||||
<div id="mfa-recovery-section" style="display:none;margin-bottom:16px;padding:14px;background:#fff8e1;border:1px solid #ffe082;border-radius:8px;">
|
||||
<h4 style="font-size:14px;color:#e65100;margin-bottom:8px;">🔐 恢复码</h4>
|
||||
<p style="font-size:13px;color:#e65100;margin-bottom:8px;">每个恢复码仅可使用一次。请妥善保管。</p>
|
||||
<div id="mfa-recovery-codes" style="font-family:monospace;font-size:13px;line-height:2;"></div>
|
||||
<div style="margin-top:8px;display:flex;gap:8px;">
|
||||
<button type="button" id="mfa-copy-recovery" class="small-btn">复制</button>
|
||||
<button type="button" id="mfa-regenerate-recovery" class="small-btn">重新生成</button>
|
||||
</div>
|
||||
<div id="mfa-recovery-msg" style="font-size:13px;color:var(--accent-red);margin-top:6px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h3>退出登录</h3>
|
||||
<button type="button" id="logout-btn" class="btn-danger">退出登录</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/theme.js"></script>
|
||||
<script src="/static/js/passkey_bridge.js"></script>
|
||||
<script src="/static/js/control.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,248 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>Cal Display</title>
|
||||
<link rel="stylesheet" href="/static/css/display.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="app">
|
||||
<!-- Left sidebar: small cards -->
|
||||
<div id="sidebar">
|
||||
<div class="card card-time" data-card="time" id="card-time">
|
||||
<div class="card-left">
|
||||
<div class="card-icon"><svg class="card-icon-svg" xmlns="http://www.w3.org/2000/svg" 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 7v5l3.5 2"/></svg></div>
|
||||
<div class="card-label">时间</div>
|
||||
</div>
|
||||
<div class="card-value" id="time-value">--:--</div>
|
||||
</div>
|
||||
<div class="card card-weather" data-card="weather" id="card-weather">
|
||||
<div class="card-left">
|
||||
<img class="card-icon card-weather-icon" id="weather-card-icon" src="" alt="" style="display:none;">
|
||||
<div class="card-label">天气</div>
|
||||
</div>
|
||||
<div class="card-value" id="weather-temp">--°</div>
|
||||
</div>
|
||||
<div class="card card-calendar" data-card="calendar" id="card-calendar">
|
||||
<div class="card-left">
|
||||
<div class="card-icon"><svg class="card-icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M8 3v4M16 3v4M3 10h18"/></svg></div>
|
||||
<div class="card-label">日历</div>
|
||||
</div>
|
||||
<div class="card-value" id="calendar-next">无日程</div>
|
||||
</div>
|
||||
<div class="card card-alarm" data-card="alarm" id="card-alarm">
|
||||
<div class="card-left">
|
||||
<div class="card-icon"><svg class="card-icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg></div>
|
||||
<div class="card-label">闹钟</div>
|
||||
</div>
|
||||
<div class="card-value" id="alarm-next">无闹钟</div>
|
||||
</div>
|
||||
<div class="card card-countdown" data-card="countdown" id="card-countdown">
|
||||
<div class="card-left">
|
||||
<div class="card-icon"><svg class="card-icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="14" r="8"/><path d="M9.5 2h5"/><path d="M12 2v4"/><path d="M12 14l3.5-2.5"/></svg></div>
|
||||
<div class="card-label">倒计时</div>
|
||||
</div>
|
||||
<div class="card-value" id="countdown-next">无</div>
|
||||
</div>
|
||||
<div class="card card-pomodoro" data-card="pomodoro" id="card-pomodoro">
|
||||
<div class="pomo-sidebar-row">
|
||||
<div class="pomo-sidebar-btns" id="pomo-sidebar-btns">
|
||||
<button id="pomo-card-start" class="pomo-mini-btn pomo-mini-primary">开始</button>
|
||||
<button id="pomo-card-pause" class="pomo-mini-btn pomo-mini-secondary" style="display:none;">暂停</button>
|
||||
<button id="pomo-card-skip" class="pomo-mini-btn pomo-mini-secondary" style="display:none;">跳过</button>
|
||||
<button id="pomo-card-stop" class="pomo-mini-btn pomo-mini-danger" style="display:none;">停止</button>
|
||||
</div>
|
||||
<div class="pomo-sidebar-info">
|
||||
<div class="card-left">
|
||||
<div class="card-icon"><svg class="card-icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="15.5" r="6.5"/><path d="M12 9v-2.5"/><path d="M12 6.5c-2.2-.3-3.4-1.4-3.7-3 1.4.2 2.5 1 3.7 3z"/><path d="M12 6.5c2.2-.3 3.4-1.4 3.7-3-1.4.2-2.5 1-3.7 3z"/></svg></div>
|
||||
<div class="card-label">番茄钟</div>
|
||||
</div>
|
||||
<div class="card-value" id="pomodoro-value">--:--</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right panel: expanded content -->
|
||||
<div id="panel">
|
||||
<div class="panel-header-btns">
|
||||
<button id="refresh-btn" class="lock-btn" title="刷新">↻</button>
|
||||
<button id="fullscreen-btn" class="lock-btn" title="全屏">⛶</button>
|
||||
<button id="lock-btn" class="lock-btn" title="锁定">🔒</button>
|
||||
</div>
|
||||
<div id="panel-time" class="panel-content">
|
||||
<h2>时间</h2>
|
||||
<div id="detailed-time" class="big-clock">--:--:--</div>
|
||||
<div class="date-str" id="date-str"></div>
|
||||
</div>
|
||||
<div id="panel-weather" class="panel-content">
|
||||
<h2>天气</h2>
|
||||
<div class="weather-main">
|
||||
<span class="weather-temp-big" id="weather-temp-big">--°</span>
|
||||
<span class="weather-desc" id="weather-desc"></span>
|
||||
<img class="weather-icon" id="weather-icon" src="" alt="" style="display:none;">
|
||||
</div>
|
||||
<div class="weather-details" id="weather-details"></div>
|
||||
<div class="hourly-section">
|
||||
<div class="hourly-title">逐小时预报</div>
|
||||
<div class="hourly-list" id="hourly-list"></div>
|
||||
</div>
|
||||
<!-- Minutely Precipitation -->
|
||||
<div class="hourly-section" id="minutely-section" style="display:none;">
|
||||
<div class="section-title-row">
|
||||
<span class="hourly-title">分钟级降水</span>
|
||||
<button class="mini-refresh-btn" id="minutely-refresh-btn">刷新</button>
|
||||
</div>
|
||||
<div id="minutely-content"></div>
|
||||
</div>
|
||||
<!-- Weather Warnings -->
|
||||
<div class="hourly-section" id="warning-section" style="display:none;">
|
||||
<div class="section-title-row">
|
||||
<span class="hourly-title">天气预警</span>
|
||||
<button class="mini-refresh-btn" id="warning-refresh-btn">刷新</button>
|
||||
</div>
|
||||
<div id="warning-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="panel-calendar" class="panel-content">
|
||||
<h2>日程</h2>
|
||||
<div class="event-list" id="event-list"></div>
|
||||
</div>
|
||||
<div id="panel-alarm" class="panel-content">
|
||||
<h2>闹钟</h2>
|
||||
<div class="alarm-list" id="alarm-list"></div>
|
||||
<button id="alarm-add-btn" class="panel-add-btn">+ 添加闹钟</button>
|
||||
</div>
|
||||
<div id="panel-countdown" class="panel-content">
|
||||
<h2>倒计时</h2>
|
||||
<div class="countdown-list" id="countdown-list"></div>
|
||||
<button id="countdown-add-btn" class="panel-add-btn">+ 添加倒计时</button>
|
||||
</div>
|
||||
<div id="panel-pomodoro" class="panel-content">
|
||||
<div class="pomo-bg-wrap">
|
||||
<div class="pomo-bg-img"></div>
|
||||
<div class="pomo-content">
|
||||
<h2>番茄钟</h2>
|
||||
<div id="pomodoro-display">
|
||||
<div class="pomo-glass">
|
||||
<div id="pomodoro-phase" class="pomo-phase-text">工作中</div>
|
||||
<div id="pomodoro-timer" class="big-clock">--:--</div>
|
||||
<div id="pomodoro-cycles">已完成 0 组</div>
|
||||
<div id="pomodoro-next-break" class="pomo-next-break">下次: 短休息</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alarm triggered overlay (hidden by default) -->
|
||||
<div id="alarm-overlay" class="alarm-overlay hidden">
|
||||
<div class="alarm-overlay-box">
|
||||
<div class="alarm-overlay-icon">🔔</div>
|
||||
<div class="alarm-overlay-title">闹钟</div>
|
||||
<div class="alarm-overlay-info" id="alarm-overlay-info"></div>
|
||||
<button id="alarm-dismiss" class="alarm-dismiss-btn">关闭闹钟</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pomodoro triggered overlay (hidden by default) -->
|
||||
<div id="pomodoro-overlay" class="alarm-overlay hidden">
|
||||
<div class="alarm-overlay-box">
|
||||
<div class="alarm-overlay-icon">🍅</div>
|
||||
<div class="alarm-overlay-title" id="pomodoro-overlay-title">番茄钟</div>
|
||||
<div class="alarm-overlay-info" id="pomodoro-overlay-info"></div>
|
||||
<button id="pomodoro-dismiss" class="alarm-dismiss-btn">关闭铃声</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom notification overlay (hidden by default) -->
|
||||
<div id="custom-notify-overlay" class="alarm-overlay hidden">
|
||||
<div class="alarm-overlay-box">
|
||||
<div class="alarm-overlay-icon" id="custom-notify-icon">🔔</div>
|
||||
<div class="alarm-overlay-title" id="custom-notify-title">提醒</div>
|
||||
<div class="alarm-overlay-info" id="custom-notify-info"></div>
|
||||
<button id="custom-notify-dismiss" class="alarm-dismiss-btn">知道了</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alarm add/edit modal -->
|
||||
<div id="alarm-modal" class="modal-overlay hidden">
|
||||
<div class="modal-box">
|
||||
<div class="modal-title" id="alarm-modal-title">添加闹钟</div>
|
||||
<div class="modal-body">
|
||||
<!-- Name (read-only when editing, auto-generated when adding) -->
|
||||
<div class="modal-field">
|
||||
<div class="modal-label">名称</div>
|
||||
<div class="alarm-modal-name" id="alarm-modal-name">闹钟 1</div>
|
||||
</div>
|
||||
<!-- Mouse-only time picker: hours + minutes -->
|
||||
<div class="modal-field">
|
||||
<div class="modal-label">时间</div>
|
||||
<div class="time-picker">
|
||||
<div class="tp-col">
|
||||
<button class="tp-btn tp-up" data-target="hours">▲</button>
|
||||
<div class="tp-value" id="tp-hours">08</div>
|
||||
<button class="tp-btn tp-down" data-target="hours">▼</button>
|
||||
</div>
|
||||
<div class="tp-sep">:</div>
|
||||
<div class="tp-col">
|
||||
<button class="tp-btn tp-up" data-target="minutes">▲</button>
|
||||
<div class="tp-value" id="tp-minutes">00</div>
|
||||
<button class="tp-btn tp-down" data-target="minutes">▼</button>
|
||||
</div>
|
||||
<div class="tp-sep">:</div>
|
||||
<div class="tp-col">
|
||||
<button class="tp-btn tp-up" data-target="seconds">▲</button>
|
||||
<div class="tp-value" id="tp-seconds">00</div>
|
||||
<button class="tp-btn tp-down" data-target="seconds">▼</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Once-only toggle (alarm mode only) -->
|
||||
<div class="modal-field" id="alarm-once-field">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="alarm-modal-once">
|
||||
<span class="toggle-slider"></span>
|
||||
<span>仅一次</span>
|
||||
</label>
|
||||
</div>
|
||||
<!-- Day-of-week picker (hidden when once is on) -->
|
||||
<div class="modal-field" id="alarm-days-field">
|
||||
<div class="modal-label">重复</div>
|
||||
<div class="day-picker" id="alarm-modal-days">
|
||||
<button class="day-btn active" data-day="1">一</button>
|
||||
<button class="day-btn active" data-day="2">二</button>
|
||||
<button class="day-btn active" data-day="3">三</button>
|
||||
<button class="day-btn active" data-day="4">四</button>
|
||||
<button class="day-btn active" data-day="5">五</button>
|
||||
<button class="day-btn active" data-day="6">六</button>
|
||||
<button class="day-btn active" data-day="7">日</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Enabled toggle -->
|
||||
<div class="modal-field">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="alarm-modal-enabled" checked>
|
||||
<span class="toggle-slider"></span>
|
||||
<span>启用</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="alarm-modal-save" class="pomo-btn pomo-btn-primary">保存</button>
|
||||
<button id="alarm-modal-cancel" class="pomo-btn pomo-btn-secondary">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden audio for custom ringtone -->
|
||||
<audio id="alarm-audio" loop preload="auto" style="display:none"></audio>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/theme.js"></script>
|
||||
<script src="/static/js/display.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,172 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>Cal Display - Custom</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--text: #e0e0e0;
|
||||
--text-dim: #888;
|
||||
--accent: #4A90D9;
|
||||
--radius: 12px;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
width: 100vw; height: 100vh; height: 100dvh;
|
||||
overflow: hidden;
|
||||
font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
user-select: none; -webkit-user-select: none; touch-action: manipulation;
|
||||
}
|
||||
#custom-container {
|
||||
width: 100%; height: 100%;
|
||||
padding: clamp(10px, 2vw, 28px);
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
.alarm-overlay {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
width: 100vw; height: 100vh; height: 100dvh;
|
||||
background: rgba(15, 17, 23, 0.92);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.alarm-overlay.hidden { display: none; }
|
||||
.alarm-overlay-box {
|
||||
text-align: center;
|
||||
padding: clamp(24px, 5vw, 56px) clamp(32px, 6.5vw, 72px);
|
||||
background: var(--card-bg); border-radius: clamp(12px, 2vw, 24px);
|
||||
border: 2px solid #E05555;
|
||||
animation: overlay-in 0.3s ease;
|
||||
}
|
||||
@keyframes overlay-in {
|
||||
from { transform: scale(0.85); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
.alarm-overlay-icon { font-size: clamp(40px, 7vw, 80px); margin-bottom: clamp(8px, 1.5vh, 16px); }
|
||||
.alarm-overlay-title { font-size: clamp(18px, 2.75vw, 36px); font-weight: 700; margin-bottom: clamp(6px, 1vh, 12px); color: #E05555; }
|
||||
.alarm-overlay-info { font-size: clamp(14px, 2vw, 26px); color: var(--text-dim); margin-bottom: clamp(16px, 3vh, 32px); line-height: 1.6; }
|
||||
.alarm-dismiss-btn {
|
||||
padding: clamp(10px, 1.75vw, 20px) clamp(32px, 6vw, 64px);
|
||||
font-size: clamp(14px, 2.25vw, 26px); font-weight: 600;
|
||||
background: #E05555; color: #fff;
|
||||
border: none; border-radius: clamp(8px, 1.25vw, 14px); cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.alarm-dismiss-btn:active { opacity: 0.8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="custom-container">
|
||||
{{ custom_html | safe }}
|
||||
</div>
|
||||
|
||||
<!-- Custom notification overlay (hidden by default) -->
|
||||
<div id="custom-notify-overlay" class="alarm-overlay hidden">
|
||||
<div class="alarm-overlay-box">
|
||||
<div class="alarm-overlay-icon" id="custom-notify-icon">🔔</div>
|
||||
<div class="alarm-overlay-title" id="custom-notify-title">提醒</div>
|
||||
<div class="alarm-overlay-info" id="custom-notify-info"></div>
|
||||
<button id="custom-notify-dismiss" class="alarm-dismiss-btn">知道了</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Custom notification overlay
|
||||
var customNotifyActive = false;
|
||||
var customNotifyAutoDismiss = null;
|
||||
var customNotifyAudioCtx = null;
|
||||
|
||||
function showCustomNotification(data) {
|
||||
if (customNotifyActive) return;
|
||||
customNotifyActive = true;
|
||||
|
||||
var overlay = document.getElementById('custom-notify-overlay');
|
||||
var iconEl = document.getElementById('custom-notify-icon');
|
||||
var titleEl = document.getElementById('custom-notify-title');
|
||||
var infoEl = document.getElementById('custom-notify-info');
|
||||
|
||||
if (iconEl) iconEl.innerHTML = data.icon || '🔔';
|
||||
if (titleEl) titleEl.textContent = data.title || '提醒';
|
||||
if (infoEl) infoEl.textContent = data.message || '';
|
||||
if (overlay) overlay.classList.remove('hidden');
|
||||
|
||||
if (data.sound !== false) {
|
||||
playNotifySound();
|
||||
}
|
||||
|
||||
customNotifyAutoDismiss = setTimeout(function () {
|
||||
dismissCustomNotification();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function playNotifySound() {
|
||||
try {
|
||||
var ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
customNotifyAudioCtx = ctx;
|
||||
var t = ctx.currentTime;
|
||||
[0, 0.3, 0.6].forEach(function (offset) {
|
||||
var osc = ctx.createOscillator();
|
||||
var gain = ctx.createGain();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = 880;
|
||||
gain.gain.setValueAtTime(1.0, t + offset);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + offset + 0.15);
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.start(t + offset);
|
||||
osc.stop(t + offset + 0.15);
|
||||
});
|
||||
setTimeout(function () { ctx.close(); }, 2000);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function dismissCustomNotification() {
|
||||
customNotifyActive = false;
|
||||
var overlay = document.getElementById('custom-notify-overlay');
|
||||
if (overlay) overlay.classList.add('hidden');
|
||||
if (customNotifyAudioCtx) {
|
||||
try { customNotifyAudioCtx.close(); } catch (_) {}
|
||||
customNotifyAudioCtx = null;
|
||||
}
|
||||
if (customNotifyAutoDismiss) {
|
||||
clearTimeout(customNotifyAutoDismiss);
|
||||
customNotifyAutoDismiss = null;
|
||||
}
|
||||
}
|
||||
|
||||
var customNotifyDismissBtn = document.getElementById('custom-notify-dismiss');
|
||||
if (customNotifyDismissBtn) {
|
||||
customNotifyDismissBtn.addEventListener('click', dismissCustomNotification);
|
||||
}
|
||||
|
||||
// SSE: listen for control commands (force_refresh / mode_change / custom_notification)
|
||||
function connectSSE() {
|
||||
var es = new EventSource('/api/events');
|
||||
es.onmessage = function (e) {
|
||||
try {
|
||||
var msg = JSON.parse(e.data);
|
||||
if (msg.type === 'force_refresh' || msg.type === 'display_mode_changed') {
|
||||
location.reload();
|
||||
} else if (msg.type === 'custom_notification') {
|
||||
showCustomNotification(msg.data);
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
es.onerror = function () {
|
||||
es.close();
|
||||
setTimeout(connectSSE, 5000);
|
||||
};
|
||||
}
|
||||
connectSSE();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,434 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>登录 - Cal Display</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--card-bg: #1a1d2e;
|
||||
--text: #e0e0e0;
|
||||
--text-dim: #888;
|
||||
--accent: #4A90D9;
|
||||
--accent-green: #4CAF50;
|
||||
--accent-red: #E05555;
|
||||
--radius: 12px;
|
||||
}
|
||||
/* 浅色主题覆盖 */
|
||||
.theme-light {
|
||||
--bg: #e8edf4;
|
||||
--card-bg: #ffffff;
|
||||
--text: #1c2333;
|
||||
--text-dim: #5b6a7e;
|
||||
--accent: #2f6fb2;
|
||||
--accent-green: #3a9450;
|
||||
--accent-red: #c74545;
|
||||
--radius: 12px;
|
||||
}
|
||||
/* 主题切换动画 */
|
||||
html.theme-anim, html.theme-anim * {
|
||||
transition: background-color 0.55s ease, color 0.55s ease,
|
||||
border-color 0.55s ease, box-shadow 0.55s ease !important;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
width: 100vw; height: 100vh; height: 100dvh;
|
||||
overflow: hidden;
|
||||
font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
user-select: none; -webkit-user-select: none; touch-action: manipulation;
|
||||
}
|
||||
.login-box {
|
||||
width: min(92vw, 420px);
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius);
|
||||
padding: clamp(20px, 4vh, 36px) clamp(20px, 4vw, 32px);
|
||||
text-align: center;
|
||||
}
|
||||
.login-box h1 { font-size: clamp(18px, 3vw, 26px); margin-bottom: 4px; font-weight: 600; }
|
||||
.subtitle { font-size: clamp(12px, 1.8vw, 16px); color: var(--text-dim); margin-bottom: clamp(16px, 3vh, 24px); }
|
||||
|
||||
/* TOTP dots */
|
||||
.totp-dots {
|
||||
display: flex; justify-content: center; gap: clamp(8px, 1.5vw, 14px);
|
||||
margin-bottom: clamp(16px, 3vh, 28px);
|
||||
}
|
||||
.totp-dot {
|
||||
width: clamp(14px, 2.5vw, 18px); height: clamp(14px, 2.5vw, 18px);
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--text-dim);
|
||||
background: transparent;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.totp-dot.filled { background: var(--accent); border-color: var(--accent); }
|
||||
.totp-dot.error { background: var(--accent-red); border-color: var(--accent-red); }
|
||||
|
||||
/* Numpad */
|
||||
.numpad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: clamp(10px, 2vw, 16px);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.numpad button {
|
||||
font-family: inherit;
|
||||
font-size: clamp(22px, 4vw, 32px);
|
||||
font-weight: 500;
|
||||
padding: clamp(12px, 2.5vh, 20px) 0;
|
||||
border-radius: var(--radius);
|
||||
border: none;
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.numpad button:active { background: rgba(255,255,255,0.14); }
|
||||
.numpad .btn-empty { background: transparent; pointer-events: none; }
|
||||
.numpad .btn-back { font-size: clamp(16px, 3vw, 24px); color: var(--text-dim); }
|
||||
|
||||
/* Alternative methods */
|
||||
.alt-methods {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.alt-btn {
|
||||
width: 100%;
|
||||
padding: clamp(10px, 1.5vh, 14px);
|
||||
font-size: clamp(13px, 1.8vw, 16px);
|
||||
font-weight: 500;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
.alt-btn:active { background: rgba(255,255,255,0.10); color: var(--text); }
|
||||
.alt-btn .icon { margin-right: 6px; }
|
||||
|
||||
/* Recovery code input mode */
|
||||
.recovery-mode { display: none; }
|
||||
.recovery-mode.visible { display: block; }
|
||||
.recovery-mode input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
font-size: 20px;
|
||||
text-align: center;
|
||||
letter-spacing: 2px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--text-dim);
|
||||
background: rgba(255,255,255,0.04);
|
||||
color: var(--text);
|
||||
margin-bottom: 12px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.recovery-mode button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
font-size: 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--accent-green);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.msg { min-height: 22px; font-size: 14px; color: var(--accent-red); margin-top: 10px; }
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
/* ═══ 浅色主题覆盖(登录页半透明白元素)═══ */
|
||||
.theme-light .numpad button { background: #e4e9f0; }
|
||||
.theme-light .numpad button:active { background: #d7dfe8; }
|
||||
.theme-light .alt-btn { background: #f2f5fa; border-color: #d7dfe8; }
|
||||
.theme-light .alt-btn:active { background: #e4e9f0; }
|
||||
.theme-light .recovery-mode input[type="text"] { background: #fafafa; border-color: #d7dfe8; }
|
||||
</style>
|
||||
<script src="/static/js/passkey_bridge.js"></script>
|
||||
<script src="/static/js/theme.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="login-box" id="login-box">
|
||||
<h1>请输入验证码</h1>
|
||||
<p class="subtitle" id="subtitle">输入 6 位验证码或临时密码</p>
|
||||
|
||||
<!-- TOTP dots -->
|
||||
<div class="totp-dots" id="totp-dots">
|
||||
<div class="totp-dot" data-i="0"></div>
|
||||
<div class="totp-dot" data-i="1"></div>
|
||||
<div class="totp-dot" data-i="2"></div>
|
||||
<div class="totp-dot" data-i="3"></div>
|
||||
<div class="totp-dot" data-i="4"></div>
|
||||
<div class="totp-dot" data-i="5"></div>
|
||||
</div>
|
||||
|
||||
<!-- TOTP Numpad -->
|
||||
<div class="numpad" id="numpad">
|
||||
<button data-key="1">1</button>
|
||||
<button data-key="2">2</button>
|
||||
<button data-key="3">3</button>
|
||||
<button data-key="4">4</button>
|
||||
<button data-key="5">5</button>
|
||||
<button data-key="6">6</button>
|
||||
<button data-key="7">7</button>
|
||||
<button data-key="8">8</button>
|
||||
<button data-key="9">9</button>
|
||||
<button class="btn-empty"></button>
|
||||
<button data-key="0">0</button>
|
||||
<button class="btn-back" data-key="back">⌫</button>
|
||||
</div>
|
||||
|
||||
<!-- Alternative methods -->
|
||||
<div class="alt-methods" id="alt-methods">
|
||||
<button class="alt-btn" id="passkey-alt-btn"><span class="icon">🔑</span>使用 Passkey 登录</button>
|
||||
<button class="alt-btn" id="recovery-alt-btn"><span class="icon">🔐</span>使用恢复码</button>
|
||||
</div>
|
||||
|
||||
<div class="msg" id="msg"></div>
|
||||
|
||||
<!-- Recovery code input -->
|
||||
<div class="recovery-mode" id="recovery-mode">
|
||||
<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px;">输入一次性恢复码</p>
|
||||
<input type="text" id="recovery-input" placeholder="ABCD-EFGHIJ" maxlength="15" autocomplete="off">
|
||||
<button id="recovery-submit">验证恢复码</button>
|
||||
<div class="back-link" id="back-to-totp">← 返回 TOTP</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:16px;font-size:12px;color:var(--text-dim);">
|
||||
<a href="/register-request" style="color:var(--text-dim);">还没有账号?申请账号</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var code = '';
|
||||
var dots = document.querySelectorAll('.totp-dot');
|
||||
var msgEl = document.getElementById('msg');
|
||||
var redirect = new URLSearchParams(location.search).get('next') || '/';
|
||||
var view = 'display';
|
||||
|
||||
// ─── Redirect ─────────────────────────
|
||||
function _redirect(url) {
|
||||
location.href = url;
|
||||
}
|
||||
|
||||
// ─── Auth status check ──────────────────
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', '/api/auth/status');
|
||||
xhr.onload = function () {
|
||||
if (xhr.status === 200) {
|
||||
var status = JSON.parse(xhr.responseText);
|
||||
if (status.authenticated) {
|
||||
_redirect(redirect);
|
||||
}
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
|
||||
// ─── TOTP dots ──────────────────────────
|
||||
function updateDots(ok) {
|
||||
dots.forEach(function (d, i) {
|
||||
d.classList.toggle('filled', i < code.length);
|
||||
d.classList.toggle('error', ok === false);
|
||||
});
|
||||
if (ok === false) {
|
||||
setTimeout(function () {
|
||||
dots.forEach(function (d) { d.classList.remove('error'); });
|
||||
}, 600);
|
||||
}
|
||||
}
|
||||
|
||||
function submitCode() {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/auth/login');
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.onload = function () {
|
||||
if (xhr.status === 200) {
|
||||
var res = JSON.parse(xhr.responseText || '{}');
|
||||
if (res.method === 'temp_password' || res.must_configure_mfa) {
|
||||
_redirect('/mfa-setup');
|
||||
} else {
|
||||
_redirect(redirect);
|
||||
}
|
||||
} else {
|
||||
code = '';
|
||||
updateDots(false);
|
||||
msgEl.textContent = '验证码错误,请重试';
|
||||
}
|
||||
};
|
||||
xhr.onerror = function () { msgEl.textContent = '网络错误'; };
|
||||
var payload = { code: code, view: view };
|
||||
xhr.send(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
document.getElementById('numpad').addEventListener('click', function (e) {
|
||||
var key = e.target.dataset.key;
|
||||
if (!key) return;
|
||||
msgEl.textContent = '';
|
||||
if (key === 'back') {
|
||||
code = code.slice(0, -1);
|
||||
updateDots();
|
||||
return;
|
||||
}
|
||||
if (code.length < 6) {
|
||||
code += key;
|
||||
updateDots();
|
||||
if (code.length === 6) {
|
||||
submitCode();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Passkey ────────────────────────────
|
||||
function triggerPasskey() {
|
||||
msgEl.textContent = '正在请求安全密钥...';
|
||||
var btn = document.getElementById('passkey-alt-btn');
|
||||
btn.disabled = true;
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/auth/webauthn/login/begin');
|
||||
xhr.onload = function () {
|
||||
if (xhr.status !== 200) {
|
||||
msgEl.textContent = 'Passkey 不可用';
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
var options = JSON.parse(xhr.responseText);
|
||||
// Passkey 中间件:浏览器走 WebAuthn;iOS App 走原生 ASAuthorizationController
|
||||
PasskeyBridge.assertion(options)
|
||||
.then(function (credential) {
|
||||
var verify = new XMLHttpRequest();
|
||||
verify.open('POST', '/api/auth/webauthn/login/complete');
|
||||
verify.setRequestHeader('Content-Type', 'application/json');
|
||||
verify.onload = function () {
|
||||
var res = JSON.parse(verify.responseText || '{}');
|
||||
if (verify.status === 200) {
|
||||
msgEl.textContent = '✅ 登录成功!';
|
||||
setTimeout(function () { _redirect(redirect); }, 300);
|
||||
} else {
|
||||
msgEl.textContent = '❌ ' + (res.error || '验证失败');
|
||||
btn.disabled = false;
|
||||
}
|
||||
};
|
||||
var p = { credential: credential, view: view };
|
||||
verify.send(JSON.stringify(p));
|
||||
})
|
||||
.catch(function (err) {
|
||||
msgEl.textContent = '⏸️ ' + (err.message || '操作取消');
|
||||
btn.disabled = false;
|
||||
});
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
// Auto-trigger Passkey on page load (with a small delay)
|
||||
setTimeout(triggerPasskey, 800);
|
||||
|
||||
// ─── Passkey button (manual trigger) ──────
|
||||
document.getElementById('passkey-alt-btn').addEventListener('click', triggerPasskey);
|
||||
|
||||
// ─── Recovery code ──────────────────────
|
||||
document.getElementById('recovery-alt-btn').addEventListener('click', function () {
|
||||
document.getElementById('totp-dots').style.display = 'none';
|
||||
document.getElementById('numpad').style.display = 'none';
|
||||
document.getElementById('alt-methods').style.display = 'none';
|
||||
document.getElementById('recovery-mode').classList.add('visible');
|
||||
document.getElementById('subtitle').textContent = '输入一次性恢复码';
|
||||
});
|
||||
|
||||
document.getElementById('back-to-totp').addEventListener('click', function () {
|
||||
document.getElementById('totp-dots').style.display = '';
|
||||
document.getElementById('numpad').style.display = '';
|
||||
document.getElementById('alt-methods').style.display = '';
|
||||
document.getElementById('recovery-mode').classList.remove('visible');
|
||||
document.getElementById('subtitle').textContent = 'TOTP 6 位验证码';
|
||||
document.getElementById('recovery-input').value = '';
|
||||
});
|
||||
|
||||
document.getElementById('recovery-submit').addEventListener('click', function () {
|
||||
var code = document.getElementById('recovery-input').value;
|
||||
msgEl.textContent = '';
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/auth/login/recovery');
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.onload = function () {
|
||||
var res = JSON.parse(xhr.responseText || '{}');
|
||||
if (xhr.status === 200) {
|
||||
_redirect(redirect);
|
||||
} else {
|
||||
msgEl.textContent = res.error || '恢复码错误';
|
||||
}
|
||||
};
|
||||
var payload = { code: code, view: view };
|
||||
xhr.send(JSON.stringify(payload));
|
||||
});
|
||||
|
||||
document.getElementById('recovery-input').addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') {
|
||||
document.getElementById('recovery-submit').click();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── SSE background listener ────────────
|
||||
(function () {
|
||||
function connectSSE() {
|
||||
var es = new EventSource('/api/events');
|
||||
es.onmessage = function (e) {
|
||||
try {
|
||||
var msg = JSON.parse(e.data);
|
||||
if (msg.type === 'force_refresh' || msg.type === 'display_mode_changed') {
|
||||
location.href = '/';
|
||||
} else if (msg.type === 'kick_all') {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/auth/logout');
|
||||
xhr.onload = function () { location.reload(); };
|
||||
xhr.send();
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
es.onerror = function () {
|
||||
es.close();
|
||||
setTimeout(connectSSE, 5000);
|
||||
};
|
||||
}
|
||||
|
||||
connectSSE();
|
||||
})();
|
||||
|
||||
// ─── Helpers ────────────────────────────
|
||||
// Convert base64url (from server/webauthn) to ArrayBuffer
|
||||
function _base64ToArrayBuffer(b64) {
|
||||
var base64 = b64.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (base64.length % 4) base64 += '=';
|
||||
var binary = atob(base64);
|
||||
var bytes = new Uint8Array(binary.length);
|
||||
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
// Convert ArrayBuffer to base64url (for WebAuthn API)
|
||||
function _arrayBufferToBase64(buf) {
|
||||
var bytes = new Uint8Array(buf);
|
||||
var binary = '';
|
||||
for (var i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,401 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录 - Cal Display 控制端</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--card-bg: #1a1d2e;
|
||||
--text: #e0e0e0;
|
||||
--text-dim: #888;
|
||||
--accent: #4A90D9;
|
||||
--accent-green: #4CAF50;
|
||||
--accent-red: #E05555;
|
||||
--radius: 12px;
|
||||
--input-bg: #242838;
|
||||
--input-border: rgba(255,255,255,0.14);
|
||||
--divider: rgba(255,255,255,0.16);
|
||||
--card-border: rgba(255,255,255,0.10);
|
||||
--soft-bg: rgba(255,255,255,0.06);
|
||||
--shadow: 0 4px 24px rgba(0,0,0,0.30);
|
||||
}
|
||||
/* 浅色主题覆盖 */
|
||||
.theme-light {
|
||||
--bg: #e8edf4;
|
||||
--card-bg: #ffffff;
|
||||
--text: #1c2333;
|
||||
--text-dim: #5b6a7e;
|
||||
--accent: #2f6fb2;
|
||||
--accent-green: #3a9450;
|
||||
--accent-red: #c74545;
|
||||
--radius: 12px;
|
||||
--input-bg: #fafafa;
|
||||
--input-border: #d7dfe8;
|
||||
--divider: #d7dfe8;
|
||||
--card-border: transparent;
|
||||
--soft-bg: #eef2f7;
|
||||
--shadow: 0 2px 12px rgba(30,40,60,0.08);
|
||||
}
|
||||
/* 主题切换动画 */
|
||||
html.theme-anim, html.theme-anim * {
|
||||
transition: background-color 0.55s ease, color 0.55s ease,
|
||||
border-color 0.55s ease, box-shadow 0.55s ease !important;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
min-height: 100vh;
|
||||
font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.login-box {
|
||||
width: min(92vw, 400px);
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius);
|
||||
padding: 32px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
text-align: center;
|
||||
}
|
||||
.login-box h1 { font-size: 22px; margin-bottom: 8px; font-weight: 600; }
|
||||
.login-box .subtitle { font-size: 14px; color: var(--text-dim); margin-bottom: 24px; }
|
||||
|
||||
/* Passkey section */
|
||||
.passkey-section { margin-bottom: 20px; }
|
||||
.passkey-btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.passkey-btn:hover { opacity: 0.9; }
|
||||
.passkey-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.passkey-btn .icon { font-size: 22px; }
|
||||
.passkey-status { font-size: 13px; color: var(--text-dim); margin-top: 8px; min-height: 20px; }
|
||||
|
||||
/* Divider */
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 20px 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
.divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: #e0e0e0; }
|
||||
|
||||
/* TOTP form */
|
||||
.totp-section { display: none; }
|
||||
.totp-section.visible { display: block; }
|
||||
.totp-section input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
letter-spacing: 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ddd;
|
||||
background: #fafafa;
|
||||
color: var(--text);
|
||||
margin-bottom: 12px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.totp-section button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
font-size: 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.totp-section button:hover { opacity: 0.9; }
|
||||
|
||||
/* Recovery code section */
|
||||
.recovery-link {
|
||||
display: inline-block;
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.recovery-link:hover { opacity: 0.8; }
|
||||
.recovery-section { display: none; }
|
||||
.recovery-section.visible { display: block; }
|
||||
.recovery-section input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
font-size: 18px;
|
||||
text-align: center;
|
||||
letter-spacing: 2px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ddd;
|
||||
background: #fafafa;
|
||||
color: var(--text);
|
||||
margin-bottom: 12px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.recovery-section button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
font-size: 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--accent-green);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.recovery-section button:hover { opacity: 0.9; }
|
||||
|
||||
.msg { min-height: 22px; font-size: 14px; color: var(--accent-red); margin-top: 10px; }
|
||||
|
||||
/* ═══ 主题适配(登录页)═══ */
|
||||
body { background: var(--bg); }
|
||||
.login-box { background: var(--card-bg); box-shadow: var(--shadow); border: 1px solid var(--card-border); }
|
||||
.totp-section input[type="text"], .recovery-section input[type="text"] {
|
||||
background: var(--input-bg); border-color: var(--input-border); color: var(--text);
|
||||
}
|
||||
.divider::before, .divider::after { background: var(--divider); }
|
||||
</style>
|
||||
<script src="/static/js/passkey_bridge.js"></script>
|
||||
<script src="/static/js/theme.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="login-box">
|
||||
<h1>🔐 控制端登录</h1>
|
||||
<p class="subtitle" id="subtitle">正在准备登录...</p>
|
||||
|
||||
<!-- Passkey -->
|
||||
<div class="passkey-section" id="passkey-section">
|
||||
<button class="passkey-btn" id="passkey-btn">
|
||||
<span class="icon">🔑</span>
|
||||
<span>使用 Passkey 登录</span>
|
||||
</button>
|
||||
<div class="passkey-status" id="passkey-status">点击上方按钮或等待自动调起</div>
|
||||
</div>
|
||||
|
||||
<div class="divider" id="divider">或</div>
|
||||
|
||||
<!-- Code input (TOTP or temp password) -->
|
||||
<div class="totp-section" id="code-section">
|
||||
<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px;">输入验证码或临时密码 (6位数字)</p>
|
||||
<form id="code-form">
|
||||
<input type="text" id="code-input" placeholder="000000" maxlength="6" inputmode="numeric" pattern="[0-9]*" autocomplete="off" required>
|
||||
<button type="submit">登录</button>
|
||||
<div class="msg" id="code-msg"></div>
|
||||
</form>
|
||||
<span class="recovery-link" id="show-recovery">使用恢复码</span>
|
||||
</div>
|
||||
|
||||
<!-- Recovery Code -->
|
||||
<div class="recovery-section" id="recovery-section">
|
||||
<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px;">输入一次性恢复码 (格式: XXXX-XXXXXX)</p>
|
||||
<form id="recovery-form">
|
||||
<input type="text" id="recovery-input" placeholder="ABCD-EFGHIJ" maxlength="15" autocomplete="off" required>
|
||||
<button type="submit">验证</button>
|
||||
<div class="msg" id="recovery-msg"></div>
|
||||
</form>
|
||||
<span class="recovery-link" id="show-totp">返回 TOTP</span>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:20px;font-size:13px;">
|
||||
<a href="/register-request" style="color:var(--accent);text-decoration:none;">还没有账号?申请账号</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var redirect = new URLSearchParams(location.search).get('next') || '/control';
|
||||
var view = 'control';
|
||||
|
||||
// ─── Redirect ─────────────────────────
|
||||
function _redirect(url) {
|
||||
location.href = url;
|
||||
}
|
||||
|
||||
// ─── Check auth status ──────────────────
|
||||
checkStatus(function (status) {
|
||||
if (status.authenticated) {
|
||||
_redirect(redirect);
|
||||
return;
|
||||
}
|
||||
if (status.temp_pending) {
|
||||
_redirect('/mfa-setup');
|
||||
return;
|
||||
}
|
||||
// Show unified login: Passkey + 6-digit input (accepts TOTP or temp password)
|
||||
showLoginForm(status.mfa_configured);
|
||||
});
|
||||
|
||||
function checkStatus(callback) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', '/api/auth/status');
|
||||
xhr.onload = function () {
|
||||
if (xhr.status === 200) callback(JSON.parse(xhr.responseText));
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
function showLoginForm(mfaConfigured) {
|
||||
document.getElementById('passkey-section').style.display = 'block';
|
||||
document.getElementById('divider').style.display = 'flex';
|
||||
document.getElementById('code-section').classList.add('visible');
|
||||
if (mfaConfigured) {
|
||||
document.getElementById('subtitle').textContent = '使用 Passkey 或输入 TOTP 验证码';
|
||||
} else {
|
||||
document.getElementById('subtitle').textContent = '首次使用?输入控制台显示的临时密码 (6位)';
|
||||
}
|
||||
// Auto-trigger Passkey if available
|
||||
setTimeout(triggerPasskey, 500);
|
||||
}
|
||||
|
||||
// ─── Unified Login (TOTP / Temp Password) ─
|
||||
document.getElementById('code-form').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var code = document.getElementById('code-input').value;
|
||||
var msg = document.getElementById('code-msg');
|
||||
msg.textContent = '';
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/auth/login');
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.onload = function () {
|
||||
var res = JSON.parse(xhr.responseText || '{}');
|
||||
if (xhr.status === 200) {
|
||||
if (res.method === 'temp_password' || res.must_configure_mfa) {
|
||||
_redirect('/mfa-setup');
|
||||
} else {
|
||||
_redirect(redirect);
|
||||
}
|
||||
} else {
|
||||
msg.textContent = res.error || '验证失败';
|
||||
document.getElementById('code-input').value = '';
|
||||
}
|
||||
};
|
||||
var payload = { code: code, view: view };
|
||||
xhr.send(JSON.stringify(payload));
|
||||
});
|
||||
|
||||
// Auto-submit on 6 digits
|
||||
document.getElementById('code-input').addEventListener('input', function () {
|
||||
if (this.value.length === 6) {
|
||||
document.getElementById('code-form').dispatchEvent(new Event('submit'));
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Recovery Code Login ────────────────
|
||||
document.getElementById('show-recovery').addEventListener('click', function () {
|
||||
document.getElementById('code-section').classList.remove('visible');
|
||||
document.getElementById('recovery-section').classList.add('visible');
|
||||
document.getElementById('subtitle').textContent = '输入一次性恢复码';
|
||||
});
|
||||
|
||||
document.getElementById('show-totp').addEventListener('click', function () {
|
||||
document.getElementById('recovery-section').classList.remove('visible');
|
||||
document.getElementById('code-section').classList.add('visible');
|
||||
document.getElementById('subtitle').textContent = '输入验证码或临时密码 (6位数字)';
|
||||
});
|
||||
|
||||
document.getElementById('recovery-form').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var code = document.getElementById('recovery-input').value;
|
||||
var msg = document.getElementById('recovery-msg');
|
||||
msg.textContent = '';
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/auth/login/recovery');
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.onload = function () {
|
||||
var res = JSON.parse(xhr.responseText || '{}');
|
||||
if (xhr.status === 200) {
|
||||
_redirect(redirect);
|
||||
} else {
|
||||
msg.textContent = res.error || '恢复码错误';
|
||||
document.getElementById('recovery-input').value = '';
|
||||
}
|
||||
};
|
||||
var payload = { code: code, view: view };
|
||||
xhr.send(JSON.stringify(payload));
|
||||
});
|
||||
|
||||
// ─── Passkey Login ──────────────────────
|
||||
function triggerPasskey() {
|
||||
var statusEl = document.getElementById('passkey-status');
|
||||
var btn = document.getElementById('passkey-btn');
|
||||
statusEl.textContent = '正在请求安全密钥...';
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/auth/webauthn/login/begin');
|
||||
xhr.onload = function () {
|
||||
if (xhr.status !== 200) {
|
||||
statusEl.textContent = 'Passkey 不可用,请使用其他方式';
|
||||
return;
|
||||
}
|
||||
var options = JSON.parse(xhr.responseText);
|
||||
// Passkey 中间件:浏览器走 WebAuthn;iOS App 走原生 ASAuthorizationController
|
||||
PasskeyBridge.assertion(options)
|
||||
.then(function (credential) {
|
||||
var verify = new XMLHttpRequest();
|
||||
verify.open('POST', '/api/auth/webauthn/login/complete');
|
||||
verify.setRequestHeader('Content-Type', 'application/json');
|
||||
verify.onload = function () {
|
||||
var res = JSON.parse(verify.responseText || '{}');
|
||||
if (verify.status === 200) {
|
||||
statusEl.textContent = '✅ 登录成功!';
|
||||
_redirect(redirect);
|
||||
} else {
|
||||
statusEl.textContent = '❌ ' + (res.error || '验证失败');
|
||||
document.getElementById('code-input').focus();
|
||||
}
|
||||
};
|
||||
var p = { credential: credential, view: view };
|
||||
verify.send(JSON.stringify(p));
|
||||
})
|
||||
.catch(function (err) {
|
||||
statusEl.textContent = '⏸️ ' + (err.message || '操作取消');
|
||||
document.getElementById('code-input').focus();
|
||||
});
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
document.getElementById('passkey-btn').addEventListener('click', triggerPasskey);
|
||||
|
||||
// ─── Helpers ────────────────────────────
|
||||
function _base64ToArrayBuffer(b64) {
|
||||
var base64 = b64.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (base64.length % 4) base64 += '=';
|
||||
var binary = atob(base64);
|
||||
var bytes = new Uint8Array(binary.length);
|
||||
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
function _arrayBufferToBase64(buf) {
|
||||
var bytes = new Uint8Array(buf);
|
||||
var binary = '';
|
||||
for (var i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,456 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>安全设置 - Cal Display</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--card-bg: #1a1d2e;
|
||||
--text: #e0e0e0;
|
||||
--text-dim: #888;
|
||||
--accent: #4A90D9;
|
||||
--accent-green: #4CAF50;
|
||||
--accent-red: #E05555;
|
||||
--radius: 12px;
|
||||
--input-bg: #242838;
|
||||
--input-border: rgba(255,255,255,0.14);
|
||||
--divider: rgba(255,255,255,0.16);
|
||||
--card-border: rgba(255,255,255,0.10);
|
||||
--soft-bg: rgba(255,255,255,0.06);
|
||||
--shadow: 0 4px 24px rgba(0,0,0,0.30);
|
||||
}
|
||||
/* 浅色主题覆盖 */
|
||||
.theme-light {
|
||||
--bg: #e8edf4;
|
||||
--card-bg: #ffffff;
|
||||
--text: #1c2333;
|
||||
--text-dim: #5b6a7e;
|
||||
--accent: #2f6fb2;
|
||||
--accent-green: #3a9450;
|
||||
--accent-red: #c74545;
|
||||
--radius: 12px;
|
||||
--input-bg: #fafafa;
|
||||
--input-border: #d7dfe8;
|
||||
--divider: #d7dfe8;
|
||||
--card-border: transparent;
|
||||
--soft-bg: #eef2f7;
|
||||
--shadow: 0 2px 12px rgba(30,40,60,0.08);
|
||||
}
|
||||
/* 主题切换动画 */
|
||||
html.theme-anim, html.theme-anim * {
|
||||
transition: background-color 0.55s ease, color 0.55s ease,
|
||||
border-color 0.55s ease, box-shadow 0.55s ease !important;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
min-height: 100vh;
|
||||
font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.setup-box {
|
||||
width: min(92vw, 500px);
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius);
|
||||
padding: 28px 32px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
.setup-box h1 { font-size: 22px; margin-bottom: 6px; font-weight: 600; }
|
||||
.setup-box .subtitle { font-size: 14px; color: var(--text-dim); margin-bottom: 24px; }
|
||||
|
||||
.method-card {
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.method-card.active { border-color: var(--accent); }
|
||||
.method-card.done { border-color: var(--accent-green); }
|
||||
|
||||
.method-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.method-header h3 { font-size: 16px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
|
||||
.method-header .badge {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge-done { background: #e8f5e9; color: #2e7d32; }
|
||||
.badge-pending { background: #fff3e0; color: #e65100; }
|
||||
|
||||
.method-body { font-size: 14px; color: var(--text-dim); line-height: 1.6; }
|
||||
.method-body .btn {
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
.btn-primary:hover { opacity: 0.9; }
|
||||
.btn-success { background: var(--accent-green); color: #fff; }
|
||||
.btn-success:hover { opacity: 0.9; }
|
||||
.btn-secondary { background: #eee; color: var(--text); }
|
||||
.btn-secondary:hover { opacity: 0.9; }
|
||||
|
||||
.totp-setup input { font-size: 24px; letter-spacing: 8px; text-align: center; padding: 10px; width: 100%; border: 1px solid #ddd; border-radius: 8px; font-family: monospace; margin-top: 8px; }
|
||||
.totp-setup .qr-wrap { text-align: center; margin: 12px 0; }
|
||||
.totp-setup .qr-wrap img { max-width: 200px; border-radius: 8px; }
|
||||
.totp-setup .secret-display { font-family: monospace; font-size: 14px; background: #f5f5f5; padding: 8px; border-radius: 6px; text-align: center; margin: 8px 0; word-break: break-all; }
|
||||
|
||||
.passkey-status { font-size: 13px; color: var(--text-dim); margin-top: 8px; min-height: 20px; }
|
||||
|
||||
.recovery-section { margin-top: 20px; padding: 16px; background: #fff8e1; border-radius: 8px; border: 1px solid #ffe082; display: none; }
|
||||
.recovery-section.visible { display: block; }
|
||||
.recovery-section h4 { font-size: 14px; color: #e65100; margin-bottom: 8px; }
|
||||
.recovery-codes { font-family: monospace; font-size: 14px; line-height: 2; }
|
||||
.recovery-codes code { display: inline-block; background: #fff; padding: 2px 8px; border-radius: 4px; margin: 2px; border: 1px solid #e0e0e0; }
|
||||
|
||||
.finish-section { margin-top: 20px; text-align: center; }
|
||||
.finish-section .btn { padding: 14px 40px; font-size: 16px; }
|
||||
|
||||
.msg { font-size: 13px; color: var(--accent-red); margin-top: 8px; min-height: 20px; }
|
||||
.success { color: var(--accent-green); }
|
||||
|
||||
/* ═══ 主题适配(MFA 设置页)═══ */
|
||||
body { background: var(--bg); }
|
||||
.setup-box { background: var(--card-bg); box-shadow: var(--shadow); border: 1px solid var(--card-border); }
|
||||
.method-card { border-color: var(--divider); }
|
||||
.method-card.active { border-color: var(--accent); }
|
||||
.totp-setup input { background: var(--input-bg); border-color: var(--input-border); color: var(--text); }
|
||||
.secret-display { background: var(--soft-bg); }
|
||||
.btn-secondary { background: var(--soft-bg); color: var(--text); }
|
||||
.recovery-section { background: rgba(255,200,87,0.10); border-color: rgba(255,200,87,0.30); }
|
||||
.badge-done { background: rgba(74,175,80,0.18); color: #7bc47f; }
|
||||
.badge-pending { background: rgba(230,81,0,0.20); color: #ffb27a; }
|
||||
.theme-light .recovery-section { background: #fff8e1; border-color: #ffe082; }
|
||||
.theme-light .badge-done { background: #e8f5e9; color: #2e7d32; }
|
||||
.theme-light .badge-pending { background: #fff3e0; color: #e65100; }
|
||||
</style>
|
||||
<script src="/static/js/passkey_bridge.js"></script>
|
||||
<script src="/static/js/theme.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="setup-box">
|
||||
<h1>🔐 安全设置</h1>
|
||||
<p class="subtitle">请至少配置一种双重认证方式以保护你的设备</p>
|
||||
|
||||
<!-- Passkey -->
|
||||
<div class="method-card" id="passkey-card">
|
||||
<div class="method-header">
|
||||
<h3>🔑 Passkey (生物/设备密钥)</h3>
|
||||
<span class="badge badge-pending" id="passkey-badge">未配置</span>
|
||||
</div>
|
||||
<div class="method-body">
|
||||
<p>使用设备内置的安全模块(指纹、面容、PIN 或安全密钥)快速登录。</p>
|
||||
<button class="btn btn-primary" id="register-passkey-btn">注册 Passkey</button>
|
||||
<div class="passkey-status" id="passkey-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TOTP -->
|
||||
<div class="method-card" id="totp-card">
|
||||
<div class="method-header">
|
||||
<h3>📱 TOTP (身份验证器应用)</h3>
|
||||
<span class="badge badge-pending" id="totp-badge">未配置</span>
|
||||
</div>
|
||||
<div class="method-body">
|
||||
<p>使用 Google Authenticator、Microsoft Authenticator 等应用扫描二维码。</p>
|
||||
<button class="btn btn-secondary" id="setup-totp-btn">设置 TOTP</button>
|
||||
<div id="totp-setup-area" style="display:none;" class="totp-setup">
|
||||
<div class="qr-wrap" id="totp-qr-wrap"></div>
|
||||
<div class="secret-display" id="totp-secret-display"></div>
|
||||
<p style="font-size:13px;color:var(--text-dim);">在应用中添加后,输入显示的 6 位验证码确认:</p>
|
||||
<input type="text" id="totp-verify-input" maxlength="6" inputmode="numeric" pattern="[0-9]*" placeholder="000000">
|
||||
<button class="btn btn-success" id="totp-verify-btn">验证并启用</button>
|
||||
<div id="totp-msg" class="msg"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recovery Codes (shown after first MFA configured) -->
|
||||
<div class="recovery-section" id="recovery-section">
|
||||
<h4>⚠️ 恢复码(请妥善保管)</h4>
|
||||
<p style="font-size:13px;color:#e65100;margin-bottom:8px;">每个恢复码仅可使用一次。建议截图或打印保存。</p>
|
||||
<div class="recovery-codes" id="recovery-codes"></div>
|
||||
<button class="btn btn-secondary" id="copy-recovery-btn" style="margin-top:8px;">📋 复制</button>
|
||||
</div>
|
||||
|
||||
<!-- Finish -->
|
||||
<div class="finish-section">
|
||||
<button class="btn btn-success" id="finish-btn">✅ 完成设置,进入控制面板</button>
|
||||
<div id="finish-msg" class="msg" style="margin-top:8px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var passkeyDone = false;
|
||||
var totpDone = false;
|
||||
|
||||
// ─── Check MFA status ──────────────────
|
||||
function checkMFAStatus() {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', '/api/auth/mfa/status');
|
||||
xhr.onload = function () {
|
||||
if (xhr.status === 200) {
|
||||
var s = JSON.parse(xhr.responseText);
|
||||
// Temp accounts must configure ONE login method to activate the account.
|
||||
if (s.is_temp_account) {
|
||||
document.querySelector('.setup-box h1').textContent = '🔐 激活你的账号';
|
||||
document.querySelector('.subtitle').textContent =
|
||||
'请配置至少一种登录方式(Passkey 或 TOTP)。配置并验证完成后,你的临时验证码将失效,账号立即正式生效。';
|
||||
}
|
||||
if (s.passkey_enabled) { passkeyDone = true; updatePasskeyUI(true); }
|
||||
if (s.totp_enabled) { totpDone = true; updateTOTPUI(true); }
|
||||
if (s.recovery_codes_remaining > 0) {
|
||||
showRecoveryCodes();
|
||||
}
|
||||
updateFinishBtn();
|
||||
} else if (xhr.status === 401) {
|
||||
// Not logged in — redirect to login
|
||||
location.href = '/login?view=control&next=/mfa-setup';
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
checkMFAStatus();
|
||||
|
||||
// ─── Passkey ───────────────────────────
|
||||
function updatePasskeyUI(done) {
|
||||
var card = document.getElementById('passkey-card');
|
||||
var badge = document.getElementById('passkey-badge');
|
||||
if (done) {
|
||||
card.classList.add('done');
|
||||
badge.textContent = '✅ 已配置';
|
||||
badge.className = 'badge badge-done';
|
||||
document.getElementById('register-passkey-btn').textContent = '重新注册';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('register-passkey-btn').addEventListener('click', function () {
|
||||
var statusEl = document.getElementById('passkey-status');
|
||||
var btn = this;
|
||||
btn.disabled = true;
|
||||
statusEl.textContent = '正在准备注册...';
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/auth/webauthn/register/begin');
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.onload = function () {
|
||||
if (xhr.status !== 200) {
|
||||
statusEl.textContent = '❌ 获取注册参数失败';
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var options = JSON.parse(xhr.responseText);
|
||||
// Passkey 中间件:浏览器走 WebAuthn;iOS App 走原生 ASAuthorizationController
|
||||
PasskeyBridge.creation(options)
|
||||
.then(function (credential) {
|
||||
credential.user_name = options.user && options.user.name ? options.user.name : 'user';
|
||||
|
||||
var verify = new XMLHttpRequest();
|
||||
verify.open('POST', '/api/auth/webauthn/register/complete');
|
||||
verify.setRequestHeader('Content-Type', 'application/json');
|
||||
verify.onload = function () {
|
||||
var res = JSON.parse(verify.responseText || '{}');
|
||||
if (verify.status === 200) {
|
||||
statusEl.textContent = '✅ Passkey 注册成功!';
|
||||
passkeyDone = true;
|
||||
updatePasskeyUI(true);
|
||||
checkRecoveryAndFinish();
|
||||
if (res.finalized) {
|
||||
statusEl.textContent = '✅ 账号已激活!即将进入控制面板...';
|
||||
setTimeout(function () { location.href = '/'; }, 2500);
|
||||
}
|
||||
} else {
|
||||
statusEl.textContent = '❌ ' + (res.error || '注册失败');
|
||||
btn.disabled = false;
|
||||
}
|
||||
};
|
||||
verify.send(JSON.stringify({ credential: credential }));
|
||||
})
|
||||
.catch(function (err) {
|
||||
statusEl.textContent = '⏸️ ' + (err.message || '操作取消');
|
||||
btn.disabled = false;
|
||||
});
|
||||
};
|
||||
xhr.send();
|
||||
});
|
||||
|
||||
// ─── TOTP ──────────────────────────────
|
||||
function updateTOTPUI(done) {
|
||||
var card = document.getElementById('totp-card');
|
||||
var badge = document.getElementById('totp-badge');
|
||||
if (done) {
|
||||
card.classList.add('done');
|
||||
badge.textContent = '✅ 已配置';
|
||||
badge.className = 'badge badge-done';
|
||||
document.getElementById('setup-totp-btn').textContent = '重新设置';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('setup-totp-btn').addEventListener('click', function () {
|
||||
var area = document.getElementById('totp-setup-area');
|
||||
if (area.style.display === 'block') {
|
||||
area.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
area.style.display = 'block';
|
||||
document.getElementById('setup-totp-btn').textContent = '取消';
|
||||
|
||||
// Request TOTP setup from server
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/auth/totp/setup');
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.onload = function () {
|
||||
if (xhr.status !== 200) return;
|
||||
var data = JSON.parse(xhr.responseText);
|
||||
document.getElementById('totp-qr-wrap').innerHTML = '<img src="data:image/png;base64,' + data.qr_base64 + '" alt="TOTP QR Code">';
|
||||
document.getElementById('totp-secret-display').textContent = '密钥: ' + data.secret;
|
||||
};
|
||||
xhr.send();
|
||||
});
|
||||
|
||||
document.getElementById('totp-verify-btn').addEventListener('click', function () {
|
||||
var code = document.getElementById('totp-verify-input').value;
|
||||
var msg = document.getElementById('totp-msg');
|
||||
msg.textContent = '';
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/auth/totp/verify');
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.onload = function () {
|
||||
var res = JSON.parse(xhr.responseText || '{}');
|
||||
if (xhr.status === 200) {
|
||||
msg.className = 'msg success';
|
||||
msg.textContent = '✅ TOTP 已启用!';
|
||||
totpDone = true;
|
||||
updateTOTPUI(true);
|
||||
document.getElementById('setup-totp-btn').textContent = '重新设置';
|
||||
// Show recovery codes
|
||||
if (res.recovery_codes && res.recovery_codes.length > 0) {
|
||||
showRecoveryCodesList(res.recovery_codes);
|
||||
} else {
|
||||
checkMFAStatus();
|
||||
}
|
||||
checkRecoveryAndFinish();
|
||||
if (res.finalized) {
|
||||
msg.textContent = '✅ 账号已激活!即将进入控制面板...';
|
||||
setTimeout(function () { location.href = '/'; }, 2500);
|
||||
}
|
||||
} else {
|
||||
msg.textContent = res.error || '验证失败';
|
||||
}
|
||||
};
|
||||
xhr.send(JSON.stringify({ code: code }));
|
||||
});
|
||||
|
||||
// ─── Recovery codes ────────────────────
|
||||
function showRecoveryCodesList(codes) {
|
||||
var section = document.getElementById('recovery-section');
|
||||
var container = document.getElementById('recovery-codes');
|
||||
section.classList.add('visible');
|
||||
container.innerHTML = codes.map(function (c) { return '<code>' + c + '</code>'; }).join(' ');
|
||||
}
|
||||
|
||||
function showRecoveryCodes() {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', '/api/auth/recovery-codes');
|
||||
xhr.onload = function () {
|
||||
if (xhr.status === 200) {
|
||||
var data = JSON.parse(xhr.responseText);
|
||||
if (data.codes && data.codes.length > 0) {
|
||||
showRecoveryCodesList(data.codes);
|
||||
}
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
function checkRecoveryAndFinish() {
|
||||
// After any MFA is set up, fetch recovery codes
|
||||
showRecoveryCodes();
|
||||
updateFinishBtn();
|
||||
}
|
||||
|
||||
document.getElementById('copy-recovery-btn').addEventListener('click', function () {
|
||||
var text = document.getElementById('recovery-codes').textContent;
|
||||
navigator.clipboard.writeText(text).then(function () {
|
||||
document.getElementById('copy-recovery-btn').textContent = '✅ 已复制';
|
||||
setTimeout(function () {
|
||||
document.getElementById('copy-recovery-btn').textContent = '📋 复制';
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Finish ────────────────────────────
|
||||
function updateFinishBtn() {
|
||||
var btn = document.getElementById('finish-btn');
|
||||
if (passkeyDone || totpDone) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '✅ 完成设置,进入控制面板';
|
||||
} else {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⚠️ 请至少配置一种认证方式';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('finish-btn').addEventListener('click', function () {
|
||||
var msg = document.getElementById('finish-msg');
|
||||
msg.textContent = '正在完成设置...';
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/auth/mfa/complete');
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.onload = function () {
|
||||
if (xhr.status === 200) {
|
||||
location.href = '/';
|
||||
} else {
|
||||
var res = JSON.parse(xhr.responseText || '{}');
|
||||
msg.textContent = res.error || '完成失败,请重试';
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
});
|
||||
|
||||
// ─── Helpers ────────────────────────────
|
||||
// Convert base64url (from server/webauthn) to ArrayBuffer
|
||||
function _base64ToArrayBuffer(b64) {
|
||||
var base64 = b64.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (base64.length % 4) base64 += '=';
|
||||
var binary = atob(base64);
|
||||
var bytes = new Uint8Array(binary.length);
|
||||
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
// Convert ArrayBuffer to base64url (for WebAuthn API)
|
||||
function _arrayBufferToBase64(buf) {
|
||||
var bytes = new Uint8Array(buf);
|
||||
var binary = '';
|
||||
for (var i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,85 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Passkey 验证</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
min-height: 100vh;
|
||||
font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: #0f1117;
|
||||
color: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.box { text-align: center; padding: 24px; max-width: 420px; }
|
||||
.icon { font-size: 44px; margin-bottom: 16px; }
|
||||
h1 { font-size: 20px; margin-bottom: 10px; font-weight: 600; }
|
||||
p { font-size: 14px; color: rgba(255,255,255,.55); line-height: 1.7; }
|
||||
|
||||
/* 浅色主题覆盖 */
|
||||
.theme-light { background: #e8edf4; }
|
||||
.theme-light body { background: #e8edf4; }
|
||||
.theme-light .box { color: #1c2333; }
|
||||
.theme-light p { color: rgba(28,35,51,0.65); }
|
||||
/* 主题切换动画 */
|
||||
html.theme-anim, html.theme-anim * {
|
||||
transition: background-color 0.55s ease, color 0.55s ease !important;
|
||||
}
|
||||
</style>
|
||||
<script src="/static/js/theme.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<div class="icon">🔑</div>
|
||||
<h1>Passkey 验证</h1>
|
||||
<p id="tip">正在准备安全验证…</p>
|
||||
</div>
|
||||
<script src="/static/js/passkey_bridge.js"></script>
|
||||
<script>
|
||||
// 本页由 iOS 套壳 App 通过 ASWebAuthenticationSession 小窗加载(Safari 环境,
|
||||
// 原生支持 WebAuthn/Passkey,无需 App 关联域名)。页面从服务端一次性 stash
|
||||
// 取回 {op, options},走标准 WebAuthn 仪式,完成后跳回 dashboard://passkey。
|
||||
(function () {
|
||||
'use strict';
|
||||
var tip = document.getElementById('tip');
|
||||
var params = new URLSearchParams(location.search);
|
||||
var op = params.get('op');
|
||||
var sid = params.get('sid');
|
||||
|
||||
if (!op || !sid) {
|
||||
tip.textContent = '缺少 Passkey 参数,请返回重试。';
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/api/passkey/stash/' + encodeURIComponent(sid))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (!data.options) throw new Error(data.error || 'Passkey 参数已失效');
|
||||
var options = data.options;
|
||||
tip.textContent = '请在系统 Passkey 面板中完成验证…';
|
||||
// 中间件:本页运行于小窗(Safari 环境),PasskeyBridge 判定为浏览器模式,
|
||||
// 直接调用 navigator.credentials;返回纯 base64url JSON credential。
|
||||
return (op === 'creation' ? PasskeyBridge.creation(options) : PasskeyBridge.assertion(options))
|
||||
.then(function (credential) {
|
||||
var user_name = (options.user && options.user.name) ? options.user.name : '';
|
||||
// credential 较大,POST 回服务端;回调 URL 只带短 sid(避免 URL 截断)
|
||||
return fetch('/api/passkey/stash/' + encodeURIComponent(sid) + '/result', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credential: credential, user_name: user_name })
|
||||
}).then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (!res.ok) throw new Error(res.error || '结果保存失败');
|
||||
location.href = 'dashboard://passkey?sid=' + encodeURIComponent(sid);
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(function (err) {
|
||||
location.href = 'dashboard://passkey?error=' + encodeURIComponent((err && err.message) || 'Passkey 验证失败');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,151 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>申请账号 - Cal Display</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--card-bg: #1a1d2e;
|
||||
--text: #e0e0e0;
|
||||
--text-dim: #888;
|
||||
--accent: #4A90D9;
|
||||
--accent-green: #4CAF50;
|
||||
--accent-red: #E05555;
|
||||
--radius: 12px;
|
||||
--input-bg: #242838;
|
||||
--input-border: rgba(255,255,255,0.14);
|
||||
--divider: rgba(255,255,255,0.16);
|
||||
--card-border: rgba(255,255,255,0.10);
|
||||
--soft-bg: rgba(255,255,255,0.06);
|
||||
--shadow: 0 4px 24px rgba(0,0,0,0.30);
|
||||
}
|
||||
/* 浅色主题覆盖 */
|
||||
.theme-light {
|
||||
--bg: #e8edf4;
|
||||
--card-bg: #ffffff;
|
||||
--text: #1c2333;
|
||||
--text-dim: #5b6a7e;
|
||||
--accent: #2f6fb2;
|
||||
--accent-green: #3a9450;
|
||||
--accent-red: #c74545;
|
||||
--radius: 12px;
|
||||
--input-bg: #fafafa;
|
||||
--input-border: #d7dfe8;
|
||||
--divider: #d7dfe8;
|
||||
--card-border: transparent;
|
||||
--soft-bg: #eef2f7;
|
||||
--shadow: 0 2px 12px rgba(30,40,60,0.08);
|
||||
}
|
||||
/* 主题切换动画 */
|
||||
html.theme-anim, html.theme-anim * {
|
||||
transition: background-color 0.55s ease, color 0.55s ease,
|
||||
border-color 0.55s ease, box-shadow 0.55s ease !important;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
min-height: 100vh;
|
||||
font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.box {
|
||||
width: min(92vw, 420px);
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius);
|
||||
padding: 32px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
text-align: center;
|
||||
}
|
||||
.box h1 { font-size: 22px; margin-bottom: 8px; font-weight: 600; }
|
||||
.box .subtitle { font-size: 14px; color: var(--text-dim); margin-bottom: 24px; line-height: 1.6; }
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
font-size: 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ddd;
|
||||
background: #fafafa;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 13px;
|
||||
font-size: 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { opacity: 0.9; }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.msg { min-height: 22px; font-size: 14px; margin-top: 12px; }
|
||||
.msg.ok { color: var(--accent-green); }
|
||||
.msg.err { color: var(--accent-red); }
|
||||
.back { display: inline-block; margin-top: 16px; font-size: 13px; color: var(--accent); text-decoration: none; }
|
||||
|
||||
/* ═══ 主题适配(注册申请页)═══ */
|
||||
body { background: var(--bg); }
|
||||
.box { background: var(--card-bg); box-shadow: var(--shadow); border: 1px solid var(--card-border); }
|
||||
.box input[type="text"] { background: var(--input-bg); border-color: var(--input-border); color: var(--text); }
|
||||
</style>
|
||||
<script src="/static/js/theme.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="box">
|
||||
<h1>📋 申请账号</h1>
|
||||
<p class="subtitle">填写你的称呼,提交后等待管理员确认。管理员确认后你会收到一个临时验证码,用它登录并配置你自己的登录方式(Passkey 或 TOTP)。</p>
|
||||
<input type="text" id="remark-input" placeholder="你的称呼 / 备注" maxlength="64" autocomplete="off">
|
||||
<button id="submit-btn">提交申请</button>
|
||||
<div class="msg" id="msg"></div>
|
||||
<a class="back" href="/login">← 返回登录</a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var btn = document.getElementById('submit-btn');
|
||||
var input = document.getElementById('remark-input');
|
||||
var msg = document.getElementById('msg');
|
||||
|
||||
function submit() {
|
||||
var remark = input.value.trim();
|
||||
if (!remark) { msg.className = 'msg err'; msg.textContent = '请填写你的称呼'; return; }
|
||||
btn.disabled = true;
|
||||
msg.textContent = '提交中...';
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/auth/register-request');
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.onload = function () {
|
||||
var res = JSON.parse(xhr.responseText || '{}');
|
||||
if (xhr.status === 200 || xhr.status === 201) {
|
||||
msg.className = 'msg ok';
|
||||
msg.textContent = res.message || '✅ 已提交申请,请等待管理员确认';
|
||||
input.disabled = true;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '已提交';
|
||||
} else {
|
||||
msg.className = 'msg err';
|
||||
msg.textContent = res.error || '提交失败,请稍后再试';
|
||||
btn.disabled = false;
|
||||
}
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
msg.className = 'msg err';
|
||||
msg.textContent = '网络错误,请重试';
|
||||
btn.disabled = false;
|
||||
};
|
||||
xhr.send(JSON.stringify({ remark: remark }));
|
||||
}
|
||||
|
||||
btn.addEventListener('click', submit);
|
||||
input.addEventListener('keydown', function (e) { if (e.key === 'Enter') submit(); });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user