fix(ui): 空态居中、大运页去重、星历加日历、顶栏动作按 46px 收

真机走查四条:

1. 空态不居中——问候+输入框+pill 整组贴在屏幕底部。加一个可压缩的
   配重行,让 chat-panel 变成 46px/1fr/auto/1fr,两个 1fr 抵消,
   整组居中;composer 仍是同一个 DOM 位置,不重挂载。

2. 大运页「Vimshottari」出现两遍、「当前 当前 Chara 段…」两个当前。
   两条轨道的 method 与自身 title 同字符串,method 只在不同时才渲染;
   兜底文案自带的「当前」去掉——它同时被参数表当「当前大运」的值用,
   不能把前缀塞进数据。

3. 星历只能一天一天点。日期改成可点的日历弹层,复用出生日期字段
   同一套 Calendar+Popover。新增 parseEphemerisDate/toEphemerisDate,
   都走本地日历字段、不经 UTC,避免跨时区差一天。

4. 「生成完整报告」在 46px 顶栏里是 44px 实心大按钮,像个横幅 CTA。
   顶栏作用域内动作统一 32px + outline,触摸目标靠 ::after 扩热区补。

测试 3372,fail 仍 31 且与基线逐条一致;四个路由标记不变;
CSS gzip 40,080。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
This commit is contained in:
Jesse_Chen
2026-09-16 08:06:12 +00:00
co-authored by Claude Opus 5
parent d548bd0554
commit 49bc4a8ba1
8 changed files with 109 additions and 7 deletions
+25 -1
View File
@@ -853,6 +853,24 @@ button:disabled { cursor: default; opacity: .45; }
.chat-header-chart { flex: 0 1 auto; min-width: 0; overflow: hidden; color: var(--color-ink-tertiary); font-size: var(--type-caption); text-overflow: ellipsis; white-space: nowrap; }
.chat-header-actions { min-width: max-content; display: flex; flex: 0 0 auto; align-items: center; gap: var(--space-2); white-space: nowrap; }
.chat-header-actions > * { flex: 0 0 auto; }
/* The page header is 46px tall, so its actions are 32px — the button primitives
default to 44px, which is why 「生成完整报告」 read as a banner CTA parked in
the corner. The touch target comes back through the hit expansion below, the
same trade the message actions and the starter pills make. */
.chat-header-actions button {
position: relative;
min-height: 32px;
height: 32px;
padding-inline: var(--space-3);
border-radius: var(--radius-md);
font-size: var(--type-caption);
}
.chat-header-actions button::after {
position: absolute;
inset: -6px;
content: "";
}
.chat-header-actions button > svg { width: 14px; height: 14px; }
.credit-button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2); padding: 0 var(--space-3); cursor: pointer; font-size: var(--type-caption); font-variant-numeric: tabular-nums; transition: background-color 120ms ease-out, transform 120ms ease-out; min-width: 64px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas-soft); color: var(--color-ink-secondary); font-weight: 500; }
.conversation { min-width: 0; min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; padding-bottom: var(--composer-reserve); background: var(--color-canvas); }
@@ -861,6 +879,12 @@ button:disabled { cursor: default; opacity: .45; }
stack reads as one centred group while the composer keeps the single DOM
position it has in a live session. */
.conversation.is-empty { display: grid; align-content: end; justify-items: center; padding: var(--space-8) var(--space-8) 0; }
/* With the balance row present the panel has four rows: header, the conversation
(1fr, greeting bottom-aligned), the composer (auto), and an equal 1fr below.
The two 1fr rows cancel out, so the group is vertically centred while the
composer keeps the single DOM position it holds in a live session. */
.chat-panel:has(.starter-balance) { grid-template-rows: 46px minmax(0, 1fr) auto minmax(0, 1fr); }
.starter-balance { min-height: 0; }
.welcome { width: min(820px, 100%); padding: var(--space-10) 0 var(--space-16); }
.welcome > .onboarding-message:first-child { padding-bottom: var(--space-8); }
.welcome > .onboarding-message:first-child .message-bubble p {
@@ -2208,7 +2232,7 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
.composer-wrap-starter {
border-top-color: transparent;
padding-bottom: var(--space-16);
padding-bottom: var(--space-4);
background: transparent;
backdrop-filter: none;
}
+6
View File
@@ -1807,6 +1807,12 @@ export default function Home() {
/>
)}
</div>}
{/* Balances the 1fr row above the composer so the greeting / composer /
pills group sits in the middle of the panel instead of resting on the
bottom edge. It collapses to 0 on short viewports. */}
{starterHomeVisible && !rectificationSurfaceOpen && !onboardingFormActive
? <div className="starter-balance" aria-hidden="true" />
: null}
</SidebarInset>
<AccountDialogOverlay
@@ -12,8 +12,12 @@ function Track({
<section className="chart-page-dasha-track">
<header>
<h3>{title}</h3>
<p>{method}</p>
<p> {currentLabel}</p>
{/* Both tracks ship a `method` equal to their own title, which rendered
the name twice; only show it when it actually adds something. */}
{method && method !== title ? <p>{method}</p> : null}
{/* No space, and no second : the fallback labels used to start with
it themselves, which read as Chara . */}
<p>{currentLabel}</p>
</header>
<ol className="chart-page-dasha-list">
{periods.map((period) => (
@@ -3,7 +3,11 @@
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import { CalendarIcon } from "lucide-react";
import { zhCN } from "date-fns/locale";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { SecondaryShell } from "@/components/secondary-shell";
import { setComposerDraft } from "@/lib/composer-draft";
import { parseEphemerisOkResponse, type EphemerisOkResponse } from "@/lib/ephemeris-contract";
@@ -15,6 +19,8 @@ import {
formatHouse,
qualityLabel,
shiftEphemerisDate,
parseEphemerisDate,
toEphemerisDate,
} from "@/lib/ephemeris-view";
type PageState =
@@ -94,6 +100,7 @@ export function EphemerisPage() {
onPrevious={() => activeDate && changeDate(shiftEphemerisDate(activeDate, -1))}
onNext={() => activeDate && changeDate(shiftEphemerisDate(activeDate, 1))}
onToday={() => today && changeDate(today)}
onSelectDate={(iso) => changeDate(iso)}
onAsk={askAboutDate}
/>
);
@@ -106,6 +113,7 @@ export function EphemerisView(props: {
readonly onPrevious: () => void;
readonly onNext: () => void;
readonly onToday: () => void;
readonly onSelectDate: (iso: string) => void;
readonly onAsk: () => void;
}) {
const factors = props.payload?.panchanga.available ? props.payload.panchanga.factors : [];
@@ -114,6 +122,8 @@ export function EphemerisView(props: {
const eventsUnavailable = !props.payload || props.payload.events.status === "unavailable";
const events = props.payload?.events.status === "ok" ? props.payload.events.items : [];
const isToday = Boolean(props.date) && props.date === props.today;
const [pickerOpen, setPickerOpen] = useState(false);
const selectedDay = props.date ? parseEphemerisDate(props.date) : undefined;
return (
<SecondaryShell
@@ -142,7 +152,41 @@ export function EphemerisView(props: {
>
<span aria-hidden="true"></span>
</Button>
<p className="ephemeris-date-label">{props.date ? formatEphemerisDate(props.date) : ""}</p>
{/* The date itself opens a month grid. Stepping a day at a time was the
only way to reach another date, so anything more than a few days
away meant repeated clicking. Same Calendar + Popover the birth
date field uses. */}
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger
render={<Button
type="button"
variant="ghost"
className="ephemeris-date-label"
aria-label={`${props.date ? formatEphemerisDate(props.date) : ""},选择日期`}
/>}
>
<CalendarIcon aria-hidden="true" />
<span>{props.date ? formatEphemerisDate(props.date) : ""}</span>
</PopoverTrigger>
<PopoverContent align="center" className="w-auto p-0">
<Calendar
mode="single"
locale={zhCN}
className="[--cell-size:2.5rem]"
selected={selectedDay}
defaultMonth={selectedDay}
captionLayout="dropdown"
navLayout="after"
startMonth={new Date(1900, 0)}
endMonth={new Date(2100, 11)}
onSelect={(next) => {
if (next === undefined) return;
setPickerOpen(false);
props.onSelectDate(toEphemerisDate(next));
}}
/>
</PopoverContent>
</Popover>
<Button
type="button"
variant="outline"
@@ -192,15 +192,20 @@ export function GeneratePersonalReportButton({
}
return (
/* Sized for the 46px page header it now lives in. It used to be the
default filled Button at full height, which read as a banner CTA
floating in the top-right corner rather than the header's own action. */
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0 whitespace-nowrap"
onClick={() => void handleGenerate()}
disabled={submitting}
title="根据已保存的具体出生分钟生成完整报告;未校正会标明方向性参考"
>
<FileText aria-hidden="true" />
{submitting ? "正在创建…" : "生成完整报告"}
{submitting ? "正在创建…" : "生成报告"}
</Button>
);
}
+2 -2
View File
@@ -344,7 +344,7 @@ function vimshottariTrack(chart: Record<string, unknown>, asOf: string) {
const currentAntar = current?.antardashas.find((item) => item.current);
const currentLabel = current
? `${planetZh(current.lord)}大运 / ${currentAntar ? planetZh(currentAntar.lord) : "—"}分运`
: "当前大运未落入已返回的时段";
: "未落入已返回的时段";
return {
id: "vimshottari" as const,
title: "Vimshottari",
@@ -412,7 +412,7 @@ function charaTrack(charaPacket: Record<string, unknown> | null, birthDate: stri
const currentAntar = current?.antardashas.find((item) => item.current);
const currentLabel = current
? `${current.label}${currentAntar ? ` / ${currentAntar.label}` : ""}`
: "当前 Chara 段未落入已返回的时段";
: "未落入已返回的时段";
return {
id: "chara" as const,
title: "Chara Dashakn_rao 变体)",
+18
View File
@@ -134,6 +134,24 @@ export function shiftEphemerisDate(iso: string, days: number): string {
return next.toISOString().slice(0, 10);
}
/**
* The calendar works in local `Date` objects; the API works in bare ISO days.
* Both helpers stay on the calendar fields and never round-trip through UTC,
* so a user east or west of Greenwich cannot land on the neighbouring day.
*/
export function parseEphemerisDate(iso: string): Date | undefined {
if (!isEphemerisIsoDate(iso)) return undefined;
const [year, month, day] = iso.split("-").map(Number);
return new Date(year, month - 1, day);
}
export function toEphemerisDate(value: Date): string {
const year = String(value.getFullYear()).padStart(4, "0");
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
export function formatEphemerisDate(iso: string): string {
const [year, month, day] = iso.split("-");
if (!year || !month || !day) return iso;
+1
View File
@@ -60,6 +60,7 @@ function render(payload: EphemerisOkResponse | null, date = payload?.date ?? "20
onPrevious() {},
onNext() {},
onToday() {},
onSelectDate() {},
onAsk() {},
}));
}