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:
co-authored by
Claude Opus 5
parent
d548bd0554
commit
49bc4a8ba1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 Dasha(kn_rao 变体)",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -60,6 +60,7 @@ function render(payload: EphemerisOkResponse | null, date = payload?.date ?? "20
|
||||
onPrevious() {},
|
||||
onNext() {},
|
||||
onToday() {},
|
||||
onSelectDate() {},
|
||||
onAsk() {},
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user