[阶段7.2] 隐藏 AI 模块;剩余页面迁移到设计令牌

AI 模块暂时下线(待重新设计),加 FEATURES 开关集中控制:
- 导航不显示"建议",路由也不注册 —— 只隐藏入口而保留可访问的
  URL 等于没隐藏
- 设置页的模型清单区块一并隐藏,且不再请求 /analysis/models
- 页面与后端端点都保留,重新打开是一行改动

登录/同步/建议/设置四个页面此前仍在用旧样式(共 101 处硬编码颜色),
深色模式下全部失效。已改写为设计令牌。

fix(a11y): 主按钮白字对比度不足
- 白字在 --accent 上实测 4.42:1(浅)/ 3.64:1(深),均低于正文
  所需的 4.5:1
- 新增 --accent-solid 专用于填充面(按钮、选中标签),取同一蓝色阶
  更深的一步:5.39:1 / 6.63:1;--accent 继续用于文字、边框和标记

fix(viz): 一年档柱形图不可读
- 365 根柱子在 607px 宽度下每根仅 1.66px,既看不清也远低于
  ~24px 的悬停命中下限
- 超过 90 个点时柱形自动改为面积图,并在副标题里说明原因。
  折线/面积的十字准星吸附最近点,本就不依赖逐标记命中区

其他:
- 统计数值精度按量级取(11,013.99 既是虚假精度,又长到把单位挤到
  第二行)
- 设置页隐私说明更正:改用 OAuth 令牌后同步已不需要每次输密码

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-08-23 22:05:28 +08:00
parent a17d6dde9b
commit 9e5e77755e
11 changed files with 358 additions and 404 deletions

View File

@@ -9,6 +9,7 @@ import Recommendations from './pages/Recommendations';
import Settings from './pages/Settings'; import Settings from './pages/Settings';
import Sleep from './pages/Sleep'; import Sleep from './pages/Sleep';
import Achievements from './pages/Achievements'; import Achievements from './pages/Achievements';
import { FEATURES } from './features';
function App() { function App() {
return ( return (
@@ -27,7 +28,9 @@ function App() {
<Route path="/trends" element={<Trends />} /> <Route path="/trends" element={<Trends />} />
<Route path="/sleep" element={<Sleep />} /> <Route path="/sleep" element={<Sleep />} />
<Route path="/achievements" element={<Achievements />} /> <Route path="/achievements" element={<Achievements />} />
{FEATURES.ai && (
<Route path="/recommendations" element={<Recommendations />} /> <Route path="/recommendations" element={<Recommendations />} />
)}
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
</Routes> </Routes>
</Layout> </Layout>

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { FEATURES } from '../features';
import './Layout.css'; import './Layout.css';
interface LayoutProps { interface LayoutProps {
@@ -11,7 +12,7 @@ const NAV = [
{ path: '/trends', label: '趋势' }, { path: '/trends', label: '趋势' },
{ path: '/sleep', label: '睡眠' }, { path: '/sleep', label: '睡眠' },
{ path: '/achievements', label: '成就' }, { path: '/achievements', label: '成就' },
{ path: '/recommendations', label: '建议' }, ...(FEATURES.ai ? [{ path: '/recommendations', label: '建议' }] : []),
{ path: '/sync', label: '同步' }, { path: '/sync', label: '同步' },
{ path: '/settings', label: '设置' }, { path: '/settings', label: '设置' },
]; ];

12
client/src/features.ts Normal file
View File

@@ -0,0 +1,12 @@
/**
* Feature switches.
*
* `ai` is off while the recommendation module is being reworked: the pages and
* the backend endpoints still exist, so turning it back on is a one-line
* change rather than a rebuild. Nothing links to the route while it is off,
* and the route itself is not registered — a hidden nav entry with a live URL
* would still be reachable by typing it.
*/
export const FEATURES = {
ai: false,
};

View File

@@ -1,203 +1,76 @@
.sync-container { .sync-container {
display: grid; display: grid;
gap: 2rem; gap: 1.25rem;
max-width: 600px; max-width: 620px;
margin: 0 auto;
}
.subtitle {
color: #666;
font-size: 1rem;
margin-top: -0.5rem;
margin-bottom: 1.5rem;
} }
.status-card { .status-card {
background: white; background: var(--surface-1);
border: 1px solid #eee; border: 1px solid var(--border);
border-radius: 8px; border-radius: var(--radius);
padding: 1.5rem; padding: 1.25rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); box-shadow: var(--shadow);
} }
.status-card h3 { .status-card h3 {
margin-top: 0; margin: 0 0 0.9rem;
margin-bottom: 1rem; font-size: 0.95rem;
color: #333; font-weight: 650;
font-size: 1.1rem; color: var(--text-primary);
} }
.status-info { .status-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 0.5rem;
} }
.status-item { .status-item {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: baseline;
padding: 0.75rem; gap: 1rem;
background: #f9f9f9; padding: 0.55rem 0.7rem;
border-radius: 6px; background: var(--surface-0);
font-size: 0.95rem; border-radius: 7px;
} font-size: 0.88rem;
.status-item.error {
background: #fee;
border: 1px solid #fcc;
} }
.status-item .label { .status-item .label {
font-weight: 600; color: var(--text-muted);
color: #666; font-size: 0.82rem;
} }
.status-item .value { .status-item .value {
color: #333; color: var(--text-primary);
font-weight: 500; font-weight: 550;
text-align: right;
}
/* Colour reinforces the status word already present in the markup; it never
carries the meaning alone — the light-surface status steps are below 3:1. */
.value.status-idle { color: var(--status-good); }
.value.status-syncing { color: var(--accent); }
.value.status-error { color: var(--status-critical); }
.status-item.error {
background: color-mix(in srgb, var(--status-critical) 8%, var(--surface-0));
border: 1px solid color-mix(in srgb, var(--status-critical) 25%, transparent);
}
.status-item.error .value {
color: var(--status-critical);
font-weight: 400;
font-size: 0.8rem;
word-break: break-word;
} }
.sync-actions { .sync-actions {
display: flex; background: var(--surface-1);
flex-direction: column; border: 1px solid var(--border);
gap: 1rem; border-radius: var(--radius);
align-items: center; padding: 1.25rem;
} box-shadow: var(--shadow);
.btn-large {
width: 100%;
max-width: 300px;
padding: 1rem 2rem;
font-size: 1.1rem;
}
.help-text {
text-align: center;
color: #999;
font-size: 0.9rem;
max-width: 400px;
line-height: 1.5;
}
.error-message,
.success-message {
padding: 1rem;
border-radius: 8px;
font-size: 0.95rem;
animation: slideIn 0.3s ease-out;
}
.error-message {
background: #fee;
border: 1px solid #fcc;
color: #c33;
}
.success-message {
background: #efe;
border: 1px solid #cfc;
color: #333;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.info-box {
background: #f0f7ff;
border: 1px solid #d0e8ff;
border-radius: 8px;
padding: 1.5rem;
}
.info-box h4 {
margin-top: 0;
margin-bottom: 1rem;
color: #333;
font-size: 1rem;
}
.info-box ul {
margin: 0;
padding-left: 1.5rem;
list-style: disc;
color: #666;
line-height: 1.8;
}
.info-box li {
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
@media (max-width: 768px) {
.sync-container {
gap: 1.5rem;
}
.status-item {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.btn-large {
max-width: 100%;
}
}
.cmd {
background: #2d2d33;
color: #e6e6e6;
padding: 0.9rem 1rem;
border-radius: 6px;
font-size: 0.82rem;
line-height: 1.7;
overflow-x: auto;
margin: 0;
font-family: Menlo, Monaco, Consolas, 'Courier New', monospace;
}
.mfa-card {
border-color: #667eea;
background: #f7f8ff;
}
.code-input {
font-size: 1.5rem;
letter-spacing: 0.35em;
text-align: center;
font-family: Menlo, Monaco, Consolas, monospace;
}
.mfa-buttons {
display: flex;
gap: 0.75rem;
align-items: center;
}
.btn-plain {
background: none;
border: 1px solid #ddd;
color: #777;
padding: 0.6rem 1.1rem;
border-radius: 6px;
cursor: pointer;
font-family: inherit;
font-size: 0.95rem;
}
.btn-plain:hover {
border-color: #bbb;
color: #555;
} }
.sync-choices { .sync-choices {
@@ -206,6 +79,24 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.mfa-card {
border-color: var(--accent);
background: var(--accent-soft);
}
.code-input {
font-size: 1.4rem;
letter-spacing: 0.32em;
text-align: center;
font-family: Menlo, Monaco, Consolas, monospace;
}
.mfa-buttons {
display: flex;
gap: 0.7rem;
align-items: center;
}
.progress-block { .progress-block {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -216,7 +107,7 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: baseline; align-items: baseline;
font-size: 0.88rem; font-size: 0.9rem;
color: var(--text-primary); color: var(--text-primary);
} }
@@ -239,3 +130,55 @@
border-radius: 999px; border-radius: 999px;
transition: width 0.4s ease; transition: width 0.4s ease;
} }
.info-box {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
}
.info-box h4 {
margin: 0 0 0.7rem;
font-size: 0.9rem;
font-weight: 650;
color: var(--text-primary);
}
.info-box ul {
margin: 0;
padding-left: 1.2rem;
color: var(--text-secondary);
line-height: 1.9;
font-size: 0.85rem;
}
.info-box code {
background: var(--surface-0);
padding: 0.1rem 0.35rem;
border-radius: 4px;
font-size: 0.85em;
}
.cmd {
background: var(--surface-0);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 0.85rem 1rem;
border-radius: 7px;
font-size: 0.8rem;
line-height: 1.8;
overflow-x: auto;
margin: 0;
font-family: Menlo, Monaco, Consolas, monospace;
}
@media (max-width: 600px) {
.status-item {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.status-item .value { text-align: left; }
.sync-choices .btn { flex: 1; }
}

View File

@@ -3,154 +3,98 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 100vh; min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: var(--surface-0);
padding: 1rem; padding: 1.5rem;
} }
.login-card { .login-card {
background: white; background: var(--surface-1);
border-radius: 12px; border: 1px solid var(--border);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); border-radius: 14px;
box-shadow: var(--shadow);
width: 100%; width: 100%;
max-width: 400px; max-width: 380px;
padding: 2rem; padding: 2rem 1.85rem;
animation: slideUp 0.3s ease-out; animation: rise 0.25s ease-out;
} }
@keyframes slideUp { @keyframes rise {
from { from { opacity: 0; transform: translateY(10px); }
opacity: 0; to { opacity: 1; transform: translateY(0); }
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
.login-header { .login-header {
text-align: center; text-align: center;
margin-bottom: 2rem; margin-bottom: 1.75rem;
} }
.login-header h1 { .login-header h1 {
font-size: 1.8rem; font-size: 1.25rem;
margin: 0 0 0.5rem 0; font-weight: 680;
color: #333; margin: 0 0 0.35rem;
color: var(--text-primary);
} }
.login-header p { .login-header p {
color: #999; color: var(--text-muted);
font-size: 0.9rem; font-size: 0.84rem;
margin: 0; margin: 0;
} }
.login-tabs { .login-tabs {
display: flex; display: flex;
gap: 0;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
border-bottom: 2px solid #f0f0f0; border-bottom: 1px solid var(--border);
} }
.tab-button { .tab-button {
flex: 1; flex: 1;
padding: 0.75rem; padding: 0.6rem;
border: none; border: none;
background: none; background: none;
color: #999; color: var(--text-muted);
font-size: 1rem; font-size: 0.92rem;
cursor: pointer; cursor: pointer;
border-bottom: 3px solid transparent; border-bottom: 2px solid transparent;
transition: all 0.3s ease; margin-bottom: -1px;
margin-bottom: -2px; transition: color 0.15s ease, border-color 0.15s ease;
font-family: inherit;
} }
.tab-button:hover { .tab-button:hover {
color: #667eea; color: var(--text-primary);
} }
.tab-button.active { .tab-button.active {
color: #667eea; color: var(--accent);
border-bottom-color: #667eea; border-bottom-color: var(--accent);
} font-weight: 600;
.error-message {
background-color: #fee;
border: 1px solid #fcc;
color: #c33;
padding: 0.75rem;
border-radius: 6px;
margin-bottom: 1rem;
font-size: 0.9rem;
} }
.login-form { .login-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-weight: 600;
color: #333;
font-size: 0.9rem;
}
.form-group input {
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 1rem;
transition: all 0.3s ease;
font-family: inherit;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-group input:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
} }
.submit-button { .submit-button {
padding: 0.75rem 1rem; padding: 0.7rem 1rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: var(--accent-solid);
color: white; color: #fff;
border: none; border: none;
border-radius: 6px; border-radius: 8px;
font-size: 1rem; font-size: 0.95rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: filter 0.15s ease;
margin-top: 0.5rem; margin-top: 0.35rem;
font-family: inherit;
} }
.submit-button:hover:not(:disabled) { .submit-button:hover:not(:disabled) {
transform: translateY(-2px); filter: brightness(1.08);
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
} }
.submit-button:disabled { .submit-button:disabled {
opacity: 0.7; opacity: 0.55;
cursor: not-allowed; cursor: not-allowed;
} }
@media (max-width: 480px) {
.login-card {
padding: 1.5rem;
}
.login-header h1 {
font-size: 1.5rem;
}
}

View File

@@ -104,8 +104,8 @@
.range-tab.active, .range-tab.active,
.metric-tab.active { .metric-tab.active {
background: var(--accent); background: var(--accent-solid);
border-color: var(--accent); border-color: var(--accent-solid);
color: #fff; color: #fff;
} }
@@ -186,7 +186,7 @@
} }
.btn-primary { .btn-primary {
background: var(--accent); background: var(--accent-solid);
color: #fff; color: #fff;
} }

View File

@@ -1,104 +1,99 @@
.model-bar { .model-bar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.7rem;
flex-wrap: wrap; flex-wrap: wrap;
padding: 1rem; padding: 0.9rem 1rem;
background: #f9f9fb; background: var(--surface-1);
border: 1px solid #eee; border: 1px solid var(--border);
border-radius: 8px; border-radius: var(--radius);
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.model-bar label { .model-bar label {
font-weight: 600; font-weight: 600;
color: #555; color: var(--text-secondary);
font-size: 0.9rem; font-size: 0.85rem;
} }
.model-bar select { .model-bar select {
flex: 1; flex: 1;
min-width: 220px; min-width: 220px;
padding: 0.5rem 0.75rem; padding: 0.45rem 0.7rem;
border: 1px solid #ddd; border: 1px solid var(--border-strong);
border-radius: 6px; border-radius: 7px;
font-size: 0.95rem; font-size: 0.9rem;
font-family: inherit; font-family: inherit;
background: white; background: var(--surface-2);
color: var(--text-primary);
} }
.model-bar select:disabled { .model-bar select:disabled {
background: #f0f0f0; background: var(--surface-0);
color: #999; color: var(--text-muted);
} }
.meta-bar { .meta-bar {
padding: 0.75rem 1rem; padding: 0.7rem 1rem;
border-radius: 6px; border-radius: 8px;
font-size: 0.9rem; font-size: 0.85rem;
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
border: 1px solid var(--border);
} }
.meta-bar.ai { .meta-bar.ai {
background: #eef2ff; background: var(--accent-soft);
border: 1px solid #d6dfff; border-color: color-mix(in srgb, var(--accent) 30%, transparent);
color: #3b4a94; color: var(--text-primary);
} }
.meta-bar.rules { .meta-bar.rules {
background: #fff8e6; background: color-mix(in srgb, var(--status-warning) 12%, var(--surface-1));
border: 1px solid #ffe4a3; border-color: color-mix(in srgb, var(--status-warning) 35%, transparent);
color: #7a5a10; color: var(--text-primary);
} }
.meta-bar .fallback { .meta-bar .fallback {
opacity: 0.8; color: var(--text-secondary);
margin-left: 0.4rem; margin-left: 0.4rem;
} }
.notice { .notice {
padding: 1rem; padding: 0.9rem 1rem;
background: #fff8e6; background: color-mix(in srgb, var(--status-warning) 12%, var(--surface-1));
border: 1px solid #ffe4a3; border: 1px solid color-mix(in srgb, var(--status-warning) 35%, transparent);
border-radius: 8px; border-radius: 8px;
color: #7a5a10; color: var(--text-secondary);
font-size: 0.9rem; font-size: 0.85rem;
line-height: 1.7; line-height: 1.8;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.notice code { .notice code {
background: rgba(0, 0, 0, 0.06); background: var(--surface-0);
padding: 0.1rem 0.35rem; padding: 0.1rem 0.35rem;
border-radius: 3px; border-radius: 4px;
font-size: 0.85em; font-size: 0.88em;
} }
.rec-list { .rec-list {
display: grid; display: grid;
gap: 1rem; gap: 0.85rem;
} }
.rec-card { .rec-card {
background: white; background: var(--surface-1);
border: 1px solid #eee; border: 1px solid var(--border);
border-left: 4px solid #ccc; border-left: 3px solid var(--border-strong);
border-radius: 8px; border-radius: var(--radius);
padding: 1.1rem 1.25rem; padding: 1rem 1.15rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); box-shadow: var(--shadow);
} }
.rec-card.priority-high { /* Priority is spelled out in the badge text; the stripe is reinforcement. */
border-left-color: #e05252; .rec-card.priority-high { border-left-color: var(--status-critical); }
} .rec-card.priority-medium { border-left-color: var(--status-serious); }
.rec-card.priority-low { border-left-color: var(--status-good); }
.rec-card.priority-medium {
border-left-color: #e0a052;
}
.rec-card.priority-low {
border-left-color: #6bbf7f;
}
.rec-header { .rec-header {
display: flex; display: flex;
@@ -109,52 +104,57 @@
} }
.rec-category { .rec-category {
font-weight: 700; font-weight: 650;
color: #333; color: var(--text-primary);
font-size: 1rem; font-size: 0.95rem;
} }
.rec-priority { .rec-priority {
font-size: 0.75rem; font-size: 0.72rem;
font-weight: 700; font-weight: 650;
padding: 0.15rem 0.6rem; padding: 0.15rem 0.55rem;
border-radius: 999px; border-radius: 999px;
white-space: nowrap; white-space: nowrap;
border: 1px solid transparent;
} }
.rec-priority.priority-high { .rec-priority.priority-high {
background: #fdecec; background: color-mix(in srgb, var(--status-critical) 12%, transparent);
color: #b03030; color: var(--status-critical);
border-color: color-mix(in srgb, var(--status-critical) 30%, transparent);
} }
.rec-priority.priority-medium { .rec-priority.priority-medium {
background: #fdf3e6; background: color-mix(in srgb, var(--status-serious) 14%, transparent);
color: #9a6412; color: var(--status-serious);
border-color: color-mix(in srgb, var(--status-serious) 35%, transparent);
} }
.rec-priority.priority-low { .rec-priority.priority-low {
background: #eaf6ed; background: color-mix(in srgb, var(--status-good) 12%, transparent);
color: #35733f; color: var(--status-good);
border-color: color-mix(in srgb, var(--status-good) 30%, transparent);
} }
.rec-body { .rec-body {
color: #444; color: var(--text-secondary);
line-height: 1.75; line-height: 1.85;
margin: 0; margin: 0;
font-size: 0.9rem;
} }
.rec-based-on { .rec-based-on {
margin-top: 0.7rem; margin-top: 0.65rem;
font-size: 0.8rem; font-size: 0.76rem;
color: #999; color: var(--text-muted);
} }
.disclaimer { .disclaimer {
margin-top: 2rem; margin-top: 2rem;
padding-top: 1rem; padding-top: 1rem;
border-top: 1px solid #eee; border-top: 1px solid var(--border);
font-size: 0.82rem; font-size: 0.78rem;
color: #999; color: var(--text-muted);
text-align: center; text-align: center;
} }
@@ -163,7 +163,6 @@
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }
.model-bar select, .model-bar select,
.model-bar .btn { .model-bar .btn {
width: 100%; width: 100%;

View File

@@ -1,7 +1,7 @@
.settings-section { .settings-section {
margin-bottom: 2.5rem; margin-bottom: 2.25rem;
padding-bottom: 1.5rem; padding-bottom: 1.5rem;
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid var(--border);
} }
.settings-section:last-child { .settings-section:last-child {
@@ -9,115 +9,129 @@
} }
.settings-section h3 { .settings-section h3 {
font-size: 1.1rem; font-size: 1rem;
color: #333; font-weight: 650;
margin: 0 0 0.6rem 0; color: var(--text-primary);
margin: 0 0 0.55rem;
} }
.settings-hint { .settings-hint {
color: #777; color: var(--text-muted);
font-size: 0.9rem; font-size: 0.86rem;
line-height: 1.7; line-height: 1.8;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.settings-hint code, .settings-hint code,
.model-table code { .model-table code {
background: #f2f2f5; background: var(--surface-0);
padding: 0.1rem 0.35rem; padding: 0.1rem 0.35rem;
border-radius: 3px; border-radius: 4px;
font-size: 0.85em; font-size: 0.88em;
color: var(--text-secondary);
} }
.model-table { .model-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
font-size: 0.9rem; font-size: 0.86rem;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
} }
.model-table th { .model-table th {
text-align: left; text-align: left;
padding: 0.6rem 0.75rem; padding: 0.6rem 0.8rem;
border-bottom: 2px solid #eee; background: var(--surface-2);
color: #888; border-bottom: 1px solid var(--border);
font-size: 0.8rem; color: var(--text-muted);
font-weight: 600; font-size: 0.75rem;
font-weight: 650;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.03em; letter-spacing: 0.04em;
} }
.model-table td { .model-table td {
padding: 0.7rem 0.75rem; padding: 0.65rem 0.8rem;
border-bottom: 1px solid #f4f4f4; border-bottom: 1px solid var(--border);
color: #444; color: var(--text-secondary);
}
.model-table tbody tr:last-child td {
border-bottom: none;
} }
.model-name { .model-name {
color: #777; color: var(--text-muted);
font-size: 0.85rem; font-size: 0.82rem;
} }
/* State is spelled out in the badge text; colour only reinforces it. */
.badge { .badge {
display: inline-block; display: inline-block;
padding: 0.15rem 0.6rem; padding: 0.15rem 0.55rem;
border-radius: 999px; border-radius: 999px;
font-size: 0.75rem; font-size: 0.74rem;
font-weight: 600; font-weight: 650;
white-space: nowrap; white-space: nowrap;
border: 1px solid transparent;
} }
.badge.ok { .badge.ok {
background: #eaf6ed; background: color-mix(in srgb, var(--status-good) 12%, transparent);
color: #35733f; color: var(--status-good);
border-color: color-mix(in srgb, var(--status-good) 30%, transparent);
} }
.badge.off { .badge.off {
background: #f2f2f2; background: var(--surface-0);
color: #999; color: var(--text-muted);
border-color: var(--border);
} }
.badge-default { .badge-default {
margin-left: 0.4rem; margin-left: 0.4rem;
padding: 0.05rem 0.4rem; padding: 0.05rem 0.4rem;
background: #eef2ff; background: var(--accent-soft);
color: #4a5aa8; color: var(--accent);
border-radius: 3px; border-radius: 4px;
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 600; font-weight: 650;
} }
.settings-list { .settings-list {
margin: 0; margin: 0;
padding-left: 1.3rem; padding-left: 1.25rem;
color: #555; color: var(--text-secondary);
line-height: 1.9; line-height: 1.95;
font-size: 0.9rem; font-size: 0.88rem;
} }
.btn-danger { .btn-danger {
background: #fff; background: var(--surface-1);
color: #c04747; color: var(--status-critical);
border: 1px solid #e0b4b4; border: 1px solid color-mix(in srgb, var(--status-critical) 40%, transparent);
padding: 0.6rem 1.2rem; padding: 0.55rem 1.1rem;
border-radius: 6px; border-radius: 8px;
font-size: 0.95rem; font-size: 0.92rem;
cursor: pointer; cursor: pointer;
font-family: inherit; font-family: inherit;
transition: all 0.2s ease; transition: background 0.15s ease, border-color 0.15s ease;
} }
.btn-danger:hover { .btn-danger:hover {
background: #fdecec; background: color-mix(in srgb, var(--status-critical) 8%, var(--surface-1));
border-color: #c04747; border-color: var(--status-critical);
} }
@media (max-width: 600px) { @media (max-width: 600px) {
.model-table { .model-table {
font-size: 0.8rem; font-size: 0.78rem;
} }
.model-table th, .model-table th,
.model-table td { .model-table td {
padding: 0.5rem 0.4rem; padding: 0.45rem 0.5rem;
} }
} }

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { apiClient, ModelInfo } from '../services/api'; import { apiClient, ModelInfo } from '../services/api';
import { FEATURES } from '../features';
import './Settings.css'; import './Settings.css';
function Settings() { function Settings() {
@@ -9,6 +10,10 @@ function Settings() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
if (!FEATURES.ai) {
setLoading(false);
return;
}
apiClient apiClient
.getModels() .getModels()
.then(setModels) .then(setModels)
@@ -25,6 +30,7 @@ function Settings() {
<div className="page"> <div className="page">
<h2></h2> <h2></h2>
{FEATURES.ai && (
<section className="settings-section"> <section className="settings-section">
<h3>AI </h3> <h3>AI </h3>
<p className="settings-hint"> <p className="settings-hint">
@@ -67,16 +73,18 @@ function Settings() {
</table> </table>
)} )}
</section> </section>
)}
<section className="settings-section"> <section className="settings-section">
<h3></h3> <h3></h3>
<ul className="settings-list"> <ul className="settings-list">
<li></li> <li></li>
<li>Garmin </li>
<li> <li>
使 AI CSV Garmin OAuth
</li> </li>
<li> PBKDF2 </li>
</ul> </ul>
</section> </section>

View File

@@ -164,6 +164,16 @@ function Trends() {
const group = GROUPS.find((g) => g.id === active) ?? GROUPS[0]; const group = GROUPS.find((g) => g.id === active) ?? GROUPS[0];
/* Bars stop working long before a year fits: 365 of them across a typical
chart leaves ~1.7px each, well under a readable mark or a usable hover
target. Past this many points the same data is drawn as an area instead —
a line handles density natively because its crosshair snaps to the
nearest x rather than needing per-mark hit areas. */
const DENSE_ABOVE = 90;
const dense = days.length > DENSE_ABOVE;
const renderType =
dense && (group.type === 'bar') ? 'area' as const : group.type;
const rows = useMemo( const rows = useMemo(
() => () =>
days.map((d) => { days.map((d) => {
@@ -180,8 +190,16 @@ function Trends() {
const primary = group.series[0]; const primary = group.series[0];
const summary = stats(rows.map((r) => r[primary.key])); const summary = stats(rows.map((r) => r[primary.key]));
const fmt = (v: number) => /* Precision by magnitude: "11,013.99 步" is both false precision and long
(Math.round(v * 100) / 100).toLocaleString(undefined, { maximumFractionDigits: 2 }); enough to wrap its unit onto a second line. */
const fmt = (v: number) => {
const abs = Math.abs(v);
const decimals = abs >= 100 ? 0 : abs >= 10 ? 1 : 2;
return v.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: decimals,
});
};
return ( return (
<div className="page"> <div className="page">
@@ -241,8 +259,13 @@ function Trends() {
<Chart <Chart
title={group.label} title={group.label}
unit={group.unit} unit={group.unit}
subtitle={
dense && renderType !== group.type
? `${days.length} 天数据:柱形在此密度下不可读,已改用面积图`
: undefined
}
data={rows} data={rows}
type={group.type} type={renderType}
series={group.series} series={group.series}
height={320} height={320}
footer={group.note} footer={group.note}

View File

@@ -39,7 +39,12 @@
--status-critical: #d03b3b; --status-critical: #d03b3b;
--grid: #eceae5; --grid: #eceae5;
/* Separate token for filled surfaces (buttons, active tabs). White text on
--accent measures only 4.42:1 light / 3.64:1 dark — under the 4.5:1 body
floor — so fills use a darker step from the same blue ramp: 5.39:1 light,
6.63:1 dark. --accent stays for text, borders and marks. */
--accent: #2a78d6; --accent: #2a78d6;
--accent-solid: #256abf;
--accent-soft: #eef4fd; --accent-soft: #eef4fd;
--radius: 10px; --radius: 10px;
@@ -70,6 +75,7 @@
--grid: #2c2c28; --grid: #2c2c28;
--accent: #3987e5; --accent: #3987e5;
--accent-solid: #1c5cab;
--accent-soft: #1d2938; --accent-soft: #1d2938;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4); --shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
@@ -96,6 +102,7 @@
--grid: #2c2c28; --grid: #2c2c28;
--accent: #3987e5; --accent: #3987e5;
--accent-solid: #1c5cab;
--accent-soft: #1d2938; --accent-soft: #1d2938;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4); --shadow: 0 1px 2px rgba(0, 0, 0, 0.4);