重新设计日历视图:以用户本地时区为主视角

- CalendarView 组件重写:添加实时用户时钟头部(HH:MM:SS + 完整日期)
- 卡片重新设计:用户本地时间为主要展示,城市窗口时间为次要
- 新增 urgency badge(进行中/即将/稍后/已过)视觉标识
- 优化卡片布局:顶部城市+徽标 → 用户时间 → 倒计时 → 原因 → 底部 DEB+阶段
- CSS 全面重写:用户时钟头部、卡片分层、计时器颜色编码
- 浅色主题同步更新所有新 class 名称
This commit is contained in:
2569718930@qq.com
2026-05-10 15:34:46 +08:00
parent 0e529358c9
commit 5ff9fade5f
3 changed files with 402 additions and 171 deletions
@@ -1,20 +1,88 @@
/* Scan terminal calendar/action view styles. */
/* Scan terminal calendar/action view — user timezone oriented. */
/* ── View root ── */
.root :global(.scan-calendar-view) {
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 14px;
gap: 16px;
padding-right: 4px;
}
/* ── User clock header ── */
.root :global(.scan-calendar-header) {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px;
border: 1px solid rgba(77, 163, 255, 0.16);
border-radius: 16px;
background: linear-gradient(135deg, rgba(77, 163, 255, 0.06), rgba(34, 197, 94, 0.03));
}
.root :global(.scan-calendar-clock) {
display: flex;
flex-direction: column;
gap: 2px;
}
.root :global(.scan-calendar-clock-time) {
font-size: 28px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: var(--color-text-primary);
letter-spacing: -0.02em;
line-height: 1.1;
}
.root :global(.scan-calendar-clock-date) {
font-size: 13px;
font-weight: 600;
color: var(--color-text-secondary);
}
.root :global(.scan-calendar-clock-meta) {
font-size: 11px;
font-weight: 700;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* ── Empty state ── */
.root :global(.scan-calendar-empty) {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 64px 32px;
text-align: center;
}
.root :global(.scan-calendar-empty-title) {
font-size: 18px;
font-weight: 800;
color: var(--color-text-primary);
}
.root :global(.scan-calendar-empty-sub) {
font-size: 13px;
color: var(--color-text-secondary);
}
/* ── Groups ── */
.root :global(.scan-calendar-groups) {
display: flex;
flex-direction: column;
gap: 16px;
}
.root :global(.scan-calendar-group) {
border: 1px solid rgba(90, 123, 166, 0.12);
border-radius: 18px;
background: rgba(8, 17, 30, 0.8);
overflow: hidden;
content-visibility: auto;
contain-intrinsic-size: 460px;
}
.root :global(.scan-calendar-group-head) {
@@ -25,24 +93,32 @@
border-bottom: 1px solid rgba(90, 123, 166, 0.1);
}
.root :global(.scan-calendar-date) {
.root :global(.scan-calendar-group-label) {
font-size: 16px;
font-weight: 800;
color: #eef7ff;
color: var(--color-text-primary);
}
.root :global(.scan-calendar-count) {
font-size: 13px;
color: #8fa4c3;
}
.root :global(.scan-calendar-subtitle) {
margin-top: 4px;
color: #7f96b6;
.root :global(.scan-calendar-group-sub) {
margin-top: 3px;
color: var(--color-text-muted);
font-size: 12px;
font-weight: 700;
font-weight: 600;
}
.root :global(.scan-calendar-group-count) {
min-width: 26px;
height: 26px;
display: grid;
place-items: center;
border-radius: 999px;
background: rgba(77, 163, 255, 0.12);
color: var(--color-accent-secondary);
font-size: 12px;
font-weight: 800;
}
/* ── Card grid ── */
.root :global(.scan-calendar-grid) {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
@@ -50,121 +126,185 @@
padding: 14px;
}
.root :global(.scan-calendar-card),
.root :global(.scan-summary-card),
.root :global(.scan-settings-card) {
border: 1px solid rgba(90, 123, 166, 0.12);
border-radius: 16px;
background: linear-gradient(180deg, rgba(10, 20, 35, 0.92), rgba(8, 17, 29, 0.92));
}
/* ── Card ── */
.root :global(.scan-calendar-card) {
text-align: left;
padding: 14px;
border: 1px solid rgba(90, 123, 166, 0.12);
border-radius: 16px;
background: linear-gradient(180deg, rgba(10, 20, 35, 0.92), rgba(8, 17, 29, 0.92));
cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.root :global(.scan-calendar-card:hover) {
border-color: rgba(77, 163, 255, 0.24);
}
.root :global(.scan-calendar-card.selected) {
box-shadow: inset 0 0 0 1px rgba(23, 217, 139, 0.42);
border-color: rgba(77, 163, 255, 0.42);
box-shadow: inset 0 0 0 1px rgba(77, 163, 255, 0.14);
}
.root :global(.scan-calendar-card.peak-active) {
border-color: rgba(23, 217, 139, 0.34);
border-color: rgba(34, 197, 94, 0.3);
background: linear-gradient(180deg, rgba(12, 42, 38, 0.9), rgba(8, 20, 30, 0.94));
}
.root :global(.scan-calendar-card.peak-next) {
border-color: rgba(94, 234, 212, 0.3);
border-color: rgba(245, 158, 11, 0.24);
}
.root :global(.scan-calendar-card.peak-past) {
opacity: 0.78;
opacity: 0.72;
}
/* ── Card top row ── */
.root :global(.scan-calendar-card-top) {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.root :global(.scan-calendar-city) {
font-size: 16px;
font-size: 15px;
font-weight: 800;
color: #eef7ff;
color: var(--color-text-primary);
}
.root :global(.scan-calendar-action) {
margin-top: 8px;
.root :global(.scan-calendar-badge) {
flex-shrink: 0;
padding: 2px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.root :global(.scan-calendar-badge.tone-active) {
background: rgba(34, 197, 94, 0.14);
color: #4ade80;
}
.root :global(.scan-calendar-badge.tone-upcoming) {
background: rgba(245, 158, 11, 0.14);
color: #fbbf24;
}
.root :global(.scan-calendar-badge.tone-later) {
background: rgba(77, 163, 255, 0.12);
color: var(--color-accent-secondary);
}
.root :global(.scan-calendar-badge.tone-past) {
background: rgba(148, 163, 184, 0.12);
color: #94a3b8;
}
/* ── User local time (primary) ── */
.root :global(.scan-calendar-user-time) {
display: flex;
align-items: baseline;
flex-direction: column;
gap: 2px;
margin-bottom: 8px;
padding: 10px 12px;
border-radius: 10px;
background: rgba(77, 163, 255, 0.06);
}
.root :global(.scan-calendar-user-label) {
font-size: 10px;
font-weight: 700;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.root :global(.scan-calendar-user-time strong) {
font-size: 16px;
font-weight: 800;
color: var(--color-text-primary);
font-variant-numeric: tabular-nums;
}
/* ── Countdown timer ── */
.root :global(.scan-calendar-countdown) {
margin-bottom: 8px;
}
.root :global(.scan-calendar-countdown-timer) {
font-size: 14px;
font-weight: 800;
}
.root :global(.scan-calendar-countdown-timer.tone-active) {
color: #4ade80;
}
.root :global(.scan-calendar-countdown-timer.tone-upcoming) {
color: #fbbf24;
}
.root :global(.scan-calendar-countdown-timer.tone-later) {
color: var(--color-accent-secondary);
}
.root :global(.scan-calendar-countdown-timer.tone-past) {
color: #94a3b8;
}
/* ── Reason text ── */
.root :global(.scan-calendar-reason) {
margin: 0 0 10px;
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 600;
line-height: 1.5;
}
/* ── Card footer row ── */
.root :global(.scan-calendar-card-foot) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 15px;
font-weight: 800;
color: #dcecff;
padding-top: 8px;
border-top: 1px solid rgba(90, 123, 166, 0.08);
}
.root :global(.scan-calendar-action span) {
color: #8fa4c3;
font-size: 12px;
font-weight: 800;
}
.root :global(.scan-calendar-action b) {
color: #eef7ff;
font-size: 16px;
font-weight: 900;
}
.root :global(.scan-calendar-action.buy) {
color: #21e391;
}
.root :global(.scan-calendar-action.sell) {
color: #ff6e6e;
}
.root :global(.scan-calendar-meta) {
margin-top: 10px;
.root :global(.scan-calendar-deb) {
display: flex;
justify-content: space-between;
gap: 12px;
color: #8fa4c3;
font-size: 13px;
align-items: baseline;
gap: 6px;
}
.root :global(.scan-calendar-countdown) {
margin-top: 12px;
padding: 10px 12px;
border: 1px solid rgba(94, 234, 212, 0.18);
border-radius: 12px;
background: rgba(20, 184, 166, 0.1);
color: #5fffe8;
font-size: 15px;
font-weight: 900;
.root :global(.scan-calendar-deb span) {
font-size: 11px;
font-weight: 600;
color: var(--color-text-muted);
}
.root :global(.scan-calendar-countdown small) {
display: block;
margin-top: 4px;
color: #9fb4d2;
font-size: 12px;
.root :global(.scan-calendar-deb b) {
font-size: 14px;
font-weight: 800;
color: var(--color-text-primary);
}
.root :global(.scan-calendar-reason) {
margin: 10px 0 0;
color: #d6e4f5;
font-size: 13px;
.root :global(.scan-calendar-phase) {
font-size: 11px;
font-weight: 700;
line-height: 1.45;
color: var(--color-text-muted);
}
.root :global(.scan-summary-grid) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.root :global(.scan-summary-grid.monitor) {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.root :global(.scan-summary-card),
.root :global(.scan-settings-card) {
padding: 18px;
/* ── City window time (secondary, subtle) ── */
.root :global(.scan-calendar-city-time) {
margin-top: 8px;
padding-top: 6px;
border-top: 1px solid rgba(90, 123, 166, 0.06);
font-size: 10px;
font-weight: 600;
color: var(--color-text-muted);
}
@@ -30,7 +30,7 @@
.root :global(.scan-terminal.light .scan-detail-volume-big),
.root :global(.scan-terminal.light .scan-kv strong),
.root :global(.scan-terminal.light .scan-time-main),
.root :global(.scan-terminal.light .scan-calendar-date),
.root :global(.scan-terminal.light .scan-calendar-group-label),
.root :global(.scan-terminal.light .scan-summary-value),
.root :global(.scan-terminal.light .scan-distribution-card strong),
.root :global(.scan-terminal.light .scan-table-header),
@@ -170,24 +170,18 @@
color: #475569;
}
.root :global(.scan-terminal.light .scan-calendar-subtitle),
.root :global(.scan-terminal.light .scan-calendar-action span),
.root :global(.scan-terminal.light .scan-calendar-countdown small) {
.root :global(.scan-terminal.light .scan-calendar-group-sub),
.root :global(.scan-terminal.light .scan-calendar-deb span),
.root :global(.scan-terminal.light .scan-calendar-city-time) {
color: #647a98;
}
.root :global(.scan-terminal.light .scan-calendar-action b) {
.root :global(.scan-terminal.light .scan-calendar-deb b) {
color: #122033;
}
.root :global(.scan-terminal.light .scan-calendar-reason) {
color: #243b5a;
}
.root :global(.scan-terminal.light .scan-calendar-countdown) {
border-color: rgba(10, 160, 100, 0.18);
background: rgba(220, 252, 239, 0.68);
color: #087b55;
color: #334155;
}
.root :global(.scan-terminal.light .scan-calendar-card.peak-active) {
@@ -195,6 +189,47 @@
background: linear-gradient(180deg, rgba(220, 252, 239, 0.84), rgba(255, 255, 255, 0.86));
}
.root :global(.scan-terminal.light .scan-calendar-header) {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.06), rgba(16, 185, 129, 0.03));
border-color: rgba(37, 99, 235, 0.14);
}
.root :global(.scan-terminal.light .scan-calendar-clock-time) {
color: #0F172A;
}
.root :global(.scan-terminal.light .scan-calendar-clock-date) {
color: #475569;
}
.root :global(.scan-terminal.light .scan-calendar-clock-meta) {
color: #64748B;
}
.root :global(.scan-terminal.light .scan-calendar-user-time) {
background: rgba(37, 99, 235, 0.04);
}
.root :global(.scan-terminal.light .scan-calendar-user-label),
.root :global(.scan-terminal.light .scan-calendar-phase),
.root :global(.scan-terminal.light .scan-calendar-group-sub),
.root :global(.scan-terminal.light .scan-calendar-city-time) {
color: #64748B;
}
.root :global(.scan-terminal.light .scan-calendar-group-count) {
background: rgba(37, 99, 235, 0.1);
color: #2563EB;
}
.root :global(.scan-terminal.light .scan-calendar-empty-title) {
color: #0F172A;
}
.root :global(.scan-terminal.light .scan-calendar-empty-sub) {
color: #475569;
}
.root :global(.scan-terminal.light .scan-mode-tab.active .scan-mode-icon) {
background: rgba(16, 185, 129, 0.16);
color: #069668;
@@ -660,9 +695,9 @@
.root :global(.scan-terminal.light .panel-weather-chip),
.root :global(.scan-terminal.light .scan-distribution-card),
.root :global(.scan-terminal.light .scan-chart-legend),
.root :global(.scan-terminal.light .scan-calendar-subtitle),
.root :global(.scan-terminal.light .scan-calendar-action span),
.root :global(.scan-terminal.light .scan-calendar-countdown small),
.root :global(.scan-terminal.light .scan-calendar-group-sub),
.root :global(.scan-terminal.light .scan-calendar-deb span),
.root :global(.scan-terminal.light .scan-calendar-city-time),
.root :global(.scan-terminal.light .scan-opportunity-ai small),
.root :global(.scan-terminal.light .scan-ai-log-head span),
.root :global(.scan-terminal.light .scan-ai-log-time),
@@ -7,12 +7,27 @@ import {
buildCalendarCoreReason,
buildCalendarMeta,
dedupeCalendarRows,
formatCalendarCardShortDate,
getCalendarActionGroup,
isCalendarActionable,
type CalendarActionItem,
} from "@/components/dashboard/scan-terminal/calendar-action-utils";
function formatUserClock(now: Date) {
const h = String(now.getHours()).padStart(2, "0");
const m = String(now.getMinutes()).padStart(2, "0");
const s = String(now.getSeconds()).padStart(2, "0");
return `${h}:${m}:${s}`;
}
function formatUserFullDate(now: Date, locale: string) {
return now.toLocaleDateString(locale === "en-US" ? "en-US" : "zh-CN", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
}
const CalendarActionCard = memo(function CalendarActionCard({
item,
locale,
@@ -27,6 +42,7 @@ const CalendarActionCard = memo(function CalendarActionCard({
const { row, meta, reason } = item;
const tempSymbol = row.temp_symbol || "°C";
const phaseMeta = getWindowPhaseMeta(row, locale);
const isEn = locale === "en-US";
return (
<button
@@ -34,41 +50,60 @@ const CalendarActionCard = memo(function CalendarActionCard({
className={`scan-calendar-card peak-${meta.tone} ${selected ? "selected" : ""}`}
onClick={() => onSelectRow(row)}
>
<div className="scan-calendar-city">
{getLocalizedCityName(
row.city,
row.city_display_name || row.display_name || row.city,
locale,
)}
</div>
<div className="scan-calendar-countdown">
{meta.title}
{meta.localWindowLabel ? (
<small>
{locale === "en-US" ? "Your time: " : "本地时间:"}
{meta.localWindowLabel}
</small>
) : null}
<small>
{locale === "en-US" ? "City window: " : "城市窗口:"}
{meta.cityWindowLabel || meta.detail}
</small>
</div>
<p className="scan-calendar-reason">{reason}</p>
<div className="scan-calendar-action">
<span>{locale === "en-US" ? "DEB high" : "DEB 预测高点"}</span>
<b>
{row.deb_prediction != null
? formatTemperatureValue(row.deb_prediction, tempSymbol)
: "--"}
</b>
</div>
<div className="scan-calendar-meta">
<span>
{formatCalendarCardShortDate(row, locale)} · {row.local_time || "--"}
<div className="scan-calendar-card-top">
<div className="scan-calendar-city">
{getLocalizedCityName(
row.city,
row.city_display_name || row.display_name || row.city,
locale,
)}
</div>
<span className={`scan-calendar-badge tone-${meta.tone}`}>
{meta.tone === "active"
? isEn ? "NOW" : "进行中"
: meta.tone === "upcoming"
? isEn ? "SOON" : "即将"
: meta.tone === "later"
? isEn ? "LATER" : "稍后"
: isEn ? "PAST" : "已过"}
</span>
<span>{phaseMeta.label}</span>
</div>
<div className="scan-calendar-user-time">
<span className="scan-calendar-user-label">
{isEn ? "Your time" : "本地时间"}
</span>
<strong>{meta.localWindowLabel || meta.detail}</strong>
</div>
<div className="scan-calendar-countdown">
<span className={`scan-calendar-countdown-timer tone-${meta.tone}`}>
{meta.title}
</span>
</div>
<p className="scan-calendar-reason">{reason}</p>
<div className="scan-calendar-card-foot">
<div className="scan-calendar-deb">
<span>{isEn ? "DEB high" : "DEB 预测高点"}</span>
<b>
{row.deb_prediction != null
? formatTemperatureValue(row.deb_prediction, tempSymbol)
: "--"}
</b>
</div>
<div className="scan-calendar-phase">
<span>{phaseMeta.label}</span>
</div>
</div>
{meta.cityWindowLabel && meta.cityWindowLabel !== meta.detail ? (
<div className="scan-calendar-city-time">
{isEn ? "City window: " : "城市窗口:"}
{meta.cityWindowLabel}
</div>
) : null}
</button>
);
});
@@ -95,7 +130,7 @@ export const CalendarView = memo(function CalendarView({
useEffect(() => {
const intervalId = window.setInterval(() => {
setNowMs(Date.now());
}, 60_000);
}, 30_000);
return () => window.clearInterval(intervalId);
}, []);
@@ -139,47 +174,68 @@ export const CalendarView = memo(function CalendarView({
}));
}, [locale, nowMs, rows, snapshotMs]);
const now = new Date(nowMs);
const isEn = locale === "en-US";
if (!groups.length) {
return (
<div className="scan-empty-state compact">
<div className="scan-empty-title">
{locale === "en-US"
? "No actionable calendar windows in the next 12 hours"
<div className="scan-calendar-empty">
<div className="scan-calendar-empty-title">
{isEn
? "No actionable windows in the next 12 hours"
: "未来 12 小时内没有可行动日历窗口"}
</div>
<div className="scan-calendar-empty-sub">
{isEn
? "Check back after the next observation cycle"
: "等待下一轮观测后再查看"}
</div>
</div>
);
}
return (
<div className="scan-calendar-view">
{groups.map((group) => (
<section key={group.key} className="scan-calendar-group">
<div className="scan-calendar-group-head">
<div>
<div className="scan-calendar-date">{group.label}</div>
<div className="scan-calendar-subtitle">
{group.subtitle}
<div className="scan-calendar-header">
<div className="scan-calendar-clock">
<span className="scan-calendar-clock-time">
{formatUserClock(now)}
</span>
<span className="scan-calendar-clock-date">
{formatUserFullDate(now, locale)}
</span>
</div>
<div className="scan-calendar-clock-meta">
{isEn ? "Your local time · Auto-refreshes" : "您所在时区 · 自动刷新"}
</div>
</div>
<div className="scan-calendar-groups">
{groups.map((group) => (
<section key={group.key} className="scan-calendar-group">
<div className="scan-calendar-group-head">
<div>
<div className="scan-calendar-group-label">{group.label}</div>
<div className="scan-calendar-group-sub">{group.subtitle}</div>
</div>
<div className="scan-calendar-group-count">
{group.items.length}
</div>
</div>
<div className="scan-calendar-count">
{locale === "en-US" ? `${group.items.length} rows` : `${group.items.length}`}
<div className="scan-calendar-grid">
{group.items.map((item) => (
<CalendarActionCard
key={item.row.id}
item={item}
locale={locale}
selected={selectedRowId === item.row.id}
onSelectRow={onSelectRow}
/>
))}
</div>
</div>
<div className="scan-calendar-grid">
{group.items.map((item) => (
<CalendarActionCard
key={item.row.id}
item={item}
locale={locale}
selected={selectedRowId === item.row.id}
onSelectRow={onSelectRow}
/>
))}
</div>
</section>
))}
</section>
))}
</div>
</div>
);
});