diff --git a/client/src/App.tsx b/client/src/App.tsx
index 79de743..ccfa350 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -9,6 +9,7 @@ import Recommendations from './pages/Recommendations';
import Settings from './pages/Settings';
import Sleep from './pages/Sleep';
import Achievements from './pages/Achievements';
+import { FEATURES } from './features';
function App() {
return (
@@ -27,7 +28,9 @@ function App() {
} />
} />
} />
- } />
+ {FEATURES.ai && (
+ } />
+ )}
} />
diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx
index 7e24575..e12a0a9 100644
--- a/client/src/components/Layout.tsx
+++ b/client/src/components/Layout.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
+import { FEATURES } from '../features';
import './Layout.css';
interface LayoutProps {
@@ -11,7 +12,7 @@ const NAV = [
{ path: '/trends', label: '趋势' },
{ path: '/sleep', label: '睡眠' },
{ path: '/achievements', label: '成就' },
- { path: '/recommendations', label: '建议' },
+ ...(FEATURES.ai ? [{ path: '/recommendations', label: '建议' }] : []),
{ path: '/sync', label: '同步' },
{ path: '/settings', label: '设置' },
];
diff --git a/client/src/features.ts b/client/src/features.ts
new file mode 100644
index 0000000..c6ddf8a
--- /dev/null
+++ b/client/src/features.ts
@@ -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,
+};
diff --git a/client/src/pages/DataSync.css b/client/src/pages/DataSync.css
index 4704760..3edc7f5 100644
--- a/client/src/pages/DataSync.css
+++ b/client/src/pages/DataSync.css
@@ -1,203 +1,76 @@
.sync-container {
display: grid;
- gap: 2rem;
- max-width: 600px;
- margin: 0 auto;
-}
-
-.subtitle {
- color: #666;
- font-size: 1rem;
- margin-top: -0.5rem;
- margin-bottom: 1.5rem;
+ gap: 1.25rem;
+ max-width: 620px;
}
.status-card {
- background: white;
- border: 1px solid #eee;
- border-radius: 8px;
- padding: 1.5rem;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 1.25rem;
+ box-shadow: var(--shadow);
}
.status-card h3 {
- margin-top: 0;
- margin-bottom: 1rem;
- color: #333;
- font-size: 1.1rem;
+ margin: 0 0 0.9rem;
+ font-size: 0.95rem;
+ font-weight: 650;
+ color: var(--text-primary);
}
.status-info {
display: flex;
flex-direction: column;
- gap: 1rem;
+ gap: 0.5rem;
}
.status-item {
display: flex;
justify-content: space-between;
- align-items: center;
- padding: 0.75rem;
- background: #f9f9f9;
- border-radius: 6px;
- font-size: 0.95rem;
-}
-
-.status-item.error {
- background: #fee;
- border: 1px solid #fcc;
+ align-items: baseline;
+ gap: 1rem;
+ padding: 0.55rem 0.7rem;
+ background: var(--surface-0);
+ border-radius: 7px;
+ font-size: 0.88rem;
}
.status-item .label {
- font-weight: 600;
- color: #666;
+ color: var(--text-muted);
+ font-size: 0.82rem;
}
.status-item .value {
- color: #333;
- font-weight: 500;
+ color: var(--text-primary);
+ 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 {
- display: flex;
- flex-direction: column;
- gap: 1rem;
- align-items: center;
-}
-
-.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;
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 1.25rem;
+ box-shadow: var(--shadow);
}
.sync-choices {
@@ -206,6 +79,24 @@
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 {
display: flex;
flex-direction: column;
@@ -216,7 +107,7 @@
display: flex;
justify-content: space-between;
align-items: baseline;
- font-size: 0.88rem;
+ font-size: 0.9rem;
color: var(--text-primary);
}
@@ -239,3 +130,55 @@
border-radius: 999px;
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; }
+}
diff --git a/client/src/pages/Login.css b/client/src/pages/Login.css
index 91c7e16..bf0068a 100644
--- a/client/src/pages/Login.css
+++ b/client/src/pages/Login.css
@@ -3,154 +3,98 @@
align-items: center;
justify-content: center;
min-height: 100vh;
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- padding: 1rem;
+ background: var(--surface-0);
+ padding: 1.5rem;
}
.login-card {
- background: white;
- border-radius: 12px;
- box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ box-shadow: var(--shadow);
width: 100%;
- max-width: 400px;
- padding: 2rem;
- animation: slideUp 0.3s ease-out;
+ max-width: 380px;
+ padding: 2rem 1.85rem;
+ animation: rise 0.25s ease-out;
}
-@keyframes slideUp {
- from {
- opacity: 0;
- transform: translateY(30px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
+@keyframes rise {
+ from { opacity: 0; transform: translateY(10px); }
+ to { opacity: 1; transform: translateY(0); }
}
.login-header {
text-align: center;
- margin-bottom: 2rem;
+ margin-bottom: 1.75rem;
}
.login-header h1 {
- font-size: 1.8rem;
- margin: 0 0 0.5rem 0;
- color: #333;
+ font-size: 1.25rem;
+ font-weight: 680;
+ margin: 0 0 0.35rem;
+ color: var(--text-primary);
}
.login-header p {
- color: #999;
- font-size: 0.9rem;
+ color: var(--text-muted);
+ font-size: 0.84rem;
margin: 0;
}
.login-tabs {
display: flex;
- gap: 0;
margin-bottom: 1.5rem;
- border-bottom: 2px solid #f0f0f0;
+ border-bottom: 1px solid var(--border);
}
.tab-button {
flex: 1;
- padding: 0.75rem;
+ padding: 0.6rem;
border: none;
background: none;
- color: #999;
- font-size: 1rem;
+ color: var(--text-muted);
+ font-size: 0.92rem;
cursor: pointer;
- border-bottom: 3px solid transparent;
- transition: all 0.3s ease;
- margin-bottom: -2px;
+ border-bottom: 2px solid transparent;
+ margin-bottom: -1px;
+ transition: color 0.15s ease, border-color 0.15s ease;
+ font-family: inherit;
}
.tab-button:hover {
- color: #667eea;
+ color: var(--text-primary);
}
.tab-button.active {
- color: #667eea;
- border-bottom-color: #667eea;
-}
-
-.error-message {
- background-color: #fee;
- border: 1px solid #fcc;
- color: #c33;
- padding: 0.75rem;
- border-radius: 6px;
- margin-bottom: 1rem;
- font-size: 0.9rem;
+ color: var(--accent);
+ border-bottom-color: var(--accent);
+ font-weight: 600;
}
.login-form {
display: flex;
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 {
- padding: 0.75rem 1rem;
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- color: white;
+ padding: 0.7rem 1rem;
+ background: var(--accent-solid);
+ color: #fff;
border: none;
- border-radius: 6px;
- font-size: 1rem;
+ border-radius: 8px;
+ font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
- transition: all 0.3s ease;
- margin-top: 0.5rem;
+ transition: filter 0.15s ease;
+ margin-top: 0.35rem;
+ font-family: inherit;
}
.submit-button:hover:not(:disabled) {
- transform: translateY(-2px);
- box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
+ filter: brightness(1.08);
}
.submit-button:disabled {
- opacity: 0.7;
+ opacity: 0.55;
cursor: not-allowed;
}
-
-@media (max-width: 480px) {
- .login-card {
- padding: 1.5rem;
- }
-
- .login-header h1 {
- font-size: 1.5rem;
- }
-}
diff --git a/client/src/pages/Pages.css b/client/src/pages/Pages.css
index f56e75d..6fac603 100644
--- a/client/src/pages/Pages.css
+++ b/client/src/pages/Pages.css
@@ -104,8 +104,8 @@
.range-tab.active,
.metric-tab.active {
- background: var(--accent);
- border-color: var(--accent);
+ background: var(--accent-solid);
+ border-color: var(--accent-solid);
color: #fff;
}
@@ -186,7 +186,7 @@
}
.btn-primary {
- background: var(--accent);
+ background: var(--accent-solid);
color: #fff;
}
diff --git a/client/src/pages/Recommendations.css b/client/src/pages/Recommendations.css
index 9190c04..9711942 100644
--- a/client/src/pages/Recommendations.css
+++ b/client/src/pages/Recommendations.css
@@ -1,104 +1,99 @@
.model-bar {
display: flex;
align-items: center;
- gap: 0.75rem;
+ gap: 0.7rem;
flex-wrap: wrap;
- padding: 1rem;
- background: #f9f9fb;
- border: 1px solid #eee;
- border-radius: 8px;
+ padding: 0.9rem 1rem;
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
margin-bottom: 1rem;
}
.model-bar label {
font-weight: 600;
- color: #555;
- font-size: 0.9rem;
+ color: var(--text-secondary);
+ font-size: 0.85rem;
}
.model-bar select {
flex: 1;
min-width: 220px;
- padding: 0.5rem 0.75rem;
- border: 1px solid #ddd;
- border-radius: 6px;
- font-size: 0.95rem;
+ padding: 0.45rem 0.7rem;
+ border: 1px solid var(--border-strong);
+ border-radius: 7px;
+ font-size: 0.9rem;
font-family: inherit;
- background: white;
+ background: var(--surface-2);
+ color: var(--text-primary);
}
.model-bar select:disabled {
- background: #f0f0f0;
- color: #999;
+ background: var(--surface-0);
+ color: var(--text-muted);
}
.meta-bar {
- padding: 0.75rem 1rem;
- border-radius: 6px;
- font-size: 0.9rem;
+ padding: 0.7rem 1rem;
+ border-radius: 8px;
+ font-size: 0.85rem;
margin-bottom: 1.25rem;
+ border: 1px solid var(--border);
}
.meta-bar.ai {
- background: #eef2ff;
- border: 1px solid #d6dfff;
- color: #3b4a94;
+ background: var(--accent-soft);
+ border-color: color-mix(in srgb, var(--accent) 30%, transparent);
+ color: var(--text-primary);
}
.meta-bar.rules {
- background: #fff8e6;
- border: 1px solid #ffe4a3;
- color: #7a5a10;
+ background: color-mix(in srgb, var(--status-warning) 12%, var(--surface-1));
+ border-color: color-mix(in srgb, var(--status-warning) 35%, transparent);
+ color: var(--text-primary);
}
.meta-bar .fallback {
- opacity: 0.8;
+ color: var(--text-secondary);
margin-left: 0.4rem;
}
.notice {
- padding: 1rem;
- background: #fff8e6;
- border: 1px solid #ffe4a3;
+ padding: 0.9rem 1rem;
+ background: color-mix(in srgb, var(--status-warning) 12%, var(--surface-1));
+ border: 1px solid color-mix(in srgb, var(--status-warning) 35%, transparent);
border-radius: 8px;
- color: #7a5a10;
- font-size: 0.9rem;
- line-height: 1.7;
+ color: var(--text-secondary);
+ font-size: 0.85rem;
+ line-height: 1.8;
margin-bottom: 1rem;
}
.notice code {
- background: rgba(0, 0, 0, 0.06);
+ background: var(--surface-0);
padding: 0.1rem 0.35rem;
- border-radius: 3px;
- font-size: 0.85em;
+ border-radius: 4px;
+ font-size: 0.88em;
}
.rec-list {
display: grid;
- gap: 1rem;
+ gap: 0.85rem;
}
.rec-card {
- background: white;
- border: 1px solid #eee;
- border-left: 4px solid #ccc;
- border-radius: 8px;
- padding: 1.1rem 1.25rem;
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
+ background: var(--surface-1);
+ border: 1px solid var(--border);
+ border-left: 3px solid var(--border-strong);
+ border-radius: var(--radius);
+ padding: 1rem 1.15rem;
+ box-shadow: var(--shadow);
}
-.rec-card.priority-high {
- border-left-color: #e05252;
-}
-
-.rec-card.priority-medium {
- border-left-color: #e0a052;
-}
-
-.rec-card.priority-low {
- border-left-color: #6bbf7f;
-}
+/* Priority is spelled out in the badge text; the stripe is reinforcement. */
+.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-header {
display: flex;
@@ -109,52 +104,57 @@
}
.rec-category {
- font-weight: 700;
- color: #333;
- font-size: 1rem;
+ font-weight: 650;
+ color: var(--text-primary);
+ font-size: 0.95rem;
}
.rec-priority {
- font-size: 0.75rem;
- font-weight: 700;
- padding: 0.15rem 0.6rem;
+ font-size: 0.72rem;
+ font-weight: 650;
+ padding: 0.15rem 0.55rem;
border-radius: 999px;
white-space: nowrap;
+ border: 1px solid transparent;
}
.rec-priority.priority-high {
- background: #fdecec;
- color: #b03030;
+ background: color-mix(in srgb, var(--status-critical) 12%, transparent);
+ color: var(--status-critical);
+ border-color: color-mix(in srgb, var(--status-critical) 30%, transparent);
}
.rec-priority.priority-medium {
- background: #fdf3e6;
- color: #9a6412;
+ background: color-mix(in srgb, var(--status-serious) 14%, transparent);
+ color: var(--status-serious);
+ border-color: color-mix(in srgb, var(--status-serious) 35%, transparent);
}
.rec-priority.priority-low {
- background: #eaf6ed;
- color: #35733f;
+ background: color-mix(in srgb, var(--status-good) 12%, transparent);
+ color: var(--status-good);
+ border-color: color-mix(in srgb, var(--status-good) 30%, transparent);
}
.rec-body {
- color: #444;
- line-height: 1.75;
+ color: var(--text-secondary);
+ line-height: 1.85;
margin: 0;
+ font-size: 0.9rem;
}
.rec-based-on {
- margin-top: 0.7rem;
- font-size: 0.8rem;
- color: #999;
+ margin-top: 0.65rem;
+ font-size: 0.76rem;
+ color: var(--text-muted);
}
.disclaimer {
margin-top: 2rem;
padding-top: 1rem;
- border-top: 1px solid #eee;
- font-size: 0.82rem;
- color: #999;
+ border-top: 1px solid var(--border);
+ font-size: 0.78rem;
+ color: var(--text-muted);
text-align: center;
}
@@ -163,7 +163,6 @@
flex-direction: column;
align-items: stretch;
}
-
.model-bar select,
.model-bar .btn {
width: 100%;
diff --git a/client/src/pages/Settings.css b/client/src/pages/Settings.css
index df5dc6d..9341cd7 100644
--- a/client/src/pages/Settings.css
+++ b/client/src/pages/Settings.css
@@ -1,7 +1,7 @@
.settings-section {
- margin-bottom: 2.5rem;
+ margin-bottom: 2.25rem;
padding-bottom: 1.5rem;
- border-bottom: 1px solid #f0f0f0;
+ border-bottom: 1px solid var(--border);
}
.settings-section:last-child {
@@ -9,115 +9,129 @@
}
.settings-section h3 {
- font-size: 1.1rem;
- color: #333;
- margin: 0 0 0.6rem 0;
+ font-size: 1rem;
+ font-weight: 650;
+ color: var(--text-primary);
+ margin: 0 0 0.55rem;
}
.settings-hint {
- color: #777;
- font-size: 0.9rem;
- line-height: 1.7;
+ color: var(--text-muted);
+ font-size: 0.86rem;
+ line-height: 1.8;
margin-bottom: 1rem;
}
.settings-hint code,
.model-table code {
- background: #f2f2f5;
+ background: var(--surface-0);
padding: 0.1rem 0.35rem;
- border-radius: 3px;
- font-size: 0.85em;
+ border-radius: 4px;
+ font-size: 0.88em;
+ color: var(--text-secondary);
}
.model-table {
width: 100%;
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 {
text-align: left;
- padding: 0.6rem 0.75rem;
- border-bottom: 2px solid #eee;
- color: #888;
- font-size: 0.8rem;
- font-weight: 600;
+ padding: 0.6rem 0.8rem;
+ background: var(--surface-2);
+ border-bottom: 1px solid var(--border);
+ color: var(--text-muted);
+ font-size: 0.75rem;
+ font-weight: 650;
text-transform: uppercase;
- letter-spacing: 0.03em;
+ letter-spacing: 0.04em;
}
.model-table td {
- padding: 0.7rem 0.75rem;
- border-bottom: 1px solid #f4f4f4;
- color: #444;
+ padding: 0.65rem 0.8rem;
+ border-bottom: 1px solid var(--border);
+ color: var(--text-secondary);
+}
+
+.model-table tbody tr:last-child td {
+ border-bottom: none;
}
.model-name {
- color: #777;
- font-size: 0.85rem;
+ color: var(--text-muted);
+ font-size: 0.82rem;
}
+/* State is spelled out in the badge text; colour only reinforces it. */
.badge {
display: inline-block;
- padding: 0.15rem 0.6rem;
+ padding: 0.15rem 0.55rem;
border-radius: 999px;
- font-size: 0.75rem;
- font-weight: 600;
+ font-size: 0.74rem;
+ font-weight: 650;
white-space: nowrap;
+ border: 1px solid transparent;
}
.badge.ok {
- background: #eaf6ed;
- color: #35733f;
+ background: color-mix(in srgb, var(--status-good) 12%, transparent);
+ color: var(--status-good);
+ border-color: color-mix(in srgb, var(--status-good) 30%, transparent);
}
.badge.off {
- background: #f2f2f2;
- color: #999;
+ background: var(--surface-0);
+ color: var(--text-muted);
+ border-color: var(--border);
}
.badge-default {
margin-left: 0.4rem;
padding: 0.05rem 0.4rem;
- background: #eef2ff;
- color: #4a5aa8;
- border-radius: 3px;
+ background: var(--accent-soft);
+ color: var(--accent);
+ border-radius: 4px;
font-size: 0.7rem;
- font-weight: 600;
+ font-weight: 650;
}
.settings-list {
margin: 0;
- padding-left: 1.3rem;
- color: #555;
- line-height: 1.9;
- font-size: 0.9rem;
+ padding-left: 1.25rem;
+ color: var(--text-secondary);
+ line-height: 1.95;
+ font-size: 0.88rem;
}
.btn-danger {
- background: #fff;
- color: #c04747;
- border: 1px solid #e0b4b4;
- padding: 0.6rem 1.2rem;
- border-radius: 6px;
- font-size: 0.95rem;
+ background: var(--surface-1);
+ color: var(--status-critical);
+ border: 1px solid color-mix(in srgb, var(--status-critical) 40%, transparent);
+ padding: 0.55rem 1.1rem;
+ border-radius: 8px;
+ font-size: 0.92rem;
cursor: pointer;
font-family: inherit;
- transition: all 0.2s ease;
+ transition: background 0.15s ease, border-color 0.15s ease;
}
.btn-danger:hover {
- background: #fdecec;
- border-color: #c04747;
+ background: color-mix(in srgb, var(--status-critical) 8%, var(--surface-1));
+ border-color: var(--status-critical);
}
@media (max-width: 600px) {
.model-table {
- font-size: 0.8rem;
+ font-size: 0.78rem;
}
-
.model-table th,
.model-table td {
- padding: 0.5rem 0.4rem;
+ padding: 0.45rem 0.5rem;
}
}
diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx
index d96a990..e05eea6 100644
--- a/client/src/pages/Settings.tsx
+++ b/client/src/pages/Settings.tsx
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { apiClient, ModelInfo } from '../services/api';
+import { FEATURES } from '../features';
import './Settings.css';
function Settings() {
@@ -9,6 +10,10 @@ function Settings() {
const [loading, setLoading] = useState(true);
useEffect(() => {
+ if (!FEATURES.ai) {
+ setLoading(false);
+ return;
+ }
apiClient
.getModels()
.then(setModels)
@@ -25,6 +30,7 @@ function Settings() {
设置
+ {FEATURES.ai && (
AI 模型
@@ -67,16 +73,18 @@ function Settings() {
)}
+ )}
数据与隐私
- 健康数据保存在自建数据库中,不上传第三方服务。
- - Garmin 密码只以哈希形式存储,无法还原,每次同步需重新输入。
-
- 使用 AI 建议时,你的每日指标会以匿名 CSV 形式发送给所选模型厂商;
- 其中不含姓名、邮箱或设备标识。
+ Garmin 账号通过 OAuth 令牌授权,密码不会被保存;令牌约一年后过期,
+ 届时在「同步」页重新绑定一次即可。
+ - 本站登录密码以 PBKDF2 加盐哈希存储,无法还原。
+
diff --git a/client/src/pages/Trends.tsx b/client/src/pages/Trends.tsx
index 373f9f4..99964e5 100644
--- a/client/src/pages/Trends.tsx
+++ b/client/src/pages/Trends.tsx
@@ -164,6 +164,16 @@ function Trends() {
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(
() =>
days.map((d) => {
@@ -180,8 +190,16 @@ function Trends() {
const primary = group.series[0];
const summary = stats(rows.map((r) => r[primary.key]));
- const fmt = (v: number) =>
- (Math.round(v * 100) / 100).toLocaleString(undefined, { maximumFractionDigits: 2 });
+ /* Precision by magnitude: "11,013.99 步" is both false precision and long
+ 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 (
@@ -241,8 +259,13 @@ function Trends() {