外壳换成组件库:侧栏可拖宽,会话可排序(#105)。
Independent Staging Quality Gate / validate (push) Failing after 9m12s
Independent Staging Quality Gate / publish (push) Skipped

桌面侧栏可在 200 到 360 像素之间拖宽,拖到最左收起,宽度记在本机。手机从左边滑出。会话在同一组里拖动排序,顺序写入可空的 sidebar_order,收藏仍置顶。账户菜单、设置、反馈和退出确认改用组件库。顶栏点数只显示图标和数字。启程清单还在。菜单、拖宽、拖动排序和翻页在首屏之后再载入。

自测:tsc 退出码 0;npm run lint 0 error、121 warning;改到的前端测试 205 项全过;npm run build 后首页仍是 static;首屏 gzip 630350,相对 8f7c0f7d 的 621226 为 +1.47%。test:db 92 项里 79 过、13 败,失败名与 Windows 已知清单一致,排序列在通过的 ok 37。没有跑全量 npm test,没有跑 Python 快速门。没有登录态,没有在浏览器里拖侧栏。

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-10-25 01:41:19 +08:00
co-authored by Grok (agent loop)
parent 8f7c0f7db4
commit 352d45b2ef
59 changed files with 1721 additions and 573 deletions
@@ -2,6 +2,13 @@
import { useEffect, useState } from "react";
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
formatAccountDeletionDate,
parseAccountDeletionStatus,
@@ -64,17 +71,17 @@ export function AccountDeletionGate({ signedIn }: Readonly<{ signedIn: boolean }
}
return (
<div className="account-deletion-gate" role="alertdialog" aria-modal="true" aria-labelledby="account-deletion-gate-title" aria-describedby="account-deletion-gate-body">
<section>
<h2 id="account-deletion-gate-title">账号注销中</h2>
<p id="account-deletion-gate-body">
<AlertDialog open onOpenChange={() => { /* Pending deletion stays until 撤销注销 or 退出登录. */ }}>
<AlertDialogContent className="account-deletion-gate" aria-labelledby="account-deletion-gate-title" aria-describedby="account-deletion-gate-body">
<AlertDialogTitle id="account-deletion-gate-title">账号注销中</AlertDialogTitle>
<AlertDialogDescription id="account-deletion-gate-body">
这个账号将在 {formatAccountDeletionDate(status.scheduledFor)} 永久删除。在那之前,你可以撤销注销,恢复全部内容。
</p>
</AlertDialogDescription>
<div className="dialog-actions">
<button className="button-secondary" type="button" onClick={() => void leave()} disabled={busy}>退出登录</button>
<button className="button-primary" type="button" onClick={() => void restore()} disabled={busy}>{busy ? "正在处理" : "撤销注销"}</button>
<Button variant="outline" type="button" onClick={() => void leave()} disabled={busy}>退出登录</Button>
<Button type="button" onClick={() => void restore()} disabled={busy}>{busy ? "正在处理" : "撤销注销"}</Button>
</div>
</section>
</div>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -2,6 +2,8 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
ACCOUNT_DELETION_CONFIRM_WORD,
ACCOUNT_DELETION_DELETED_ITEMS,
@@ -55,9 +57,9 @@ export function AccountDeletionSection() {
<small>{ACCOUNT_DELETION_GRACE_DAYS} 天内可以撤销,之后永久删除</small>
</div>
{!open ? (
<button type="button" className="account-deletion-open" onClick={() => setOpen(true)}>
<Button type="button" className="account-deletion-open" variant="outline" onClick={() => setOpen(true)}>
注销账号…
</button>
</Button>
) : (
<div className="account-deletion-confirm">
<p>提交后你会立刻退出登录,账号暂停使用。{ACCOUNT_DELETION_GRACE_DAYS} 天内重新登录可以撤销;{ACCOUNT_DELETION_GRACE_DAYS} 天后以下内容会被永久删除,无法恢复:</p>
@@ -68,22 +70,22 @@ export function AccountDeletionSection() {
<p className="account-deletion-contact">有疑问可以先联系我们:{LEGAL_ENTITY.contactEmail}</p>
<label className="account-deletion-input">
<span>输入「{ACCOUNT_DELETION_CONFIRM_WORD}」确认</span>
<input
<Input
value={confirm}
onChange={(event) => setConfirm(event.target.value)}
autoComplete="off"
/>
</label>
<div className="dialog-actions">
<button className="button-secondary" type="button" onClick={() => { setOpen(false); setConfirm(""); }} disabled={submitting}>取消</button>
<button
className="button-primary danger-primary"
<Button variant="outline" type="button" onClick={() => { setOpen(false); setConfirm(""); }} disabled={submitting}>取消</Button>
<Button
variant="destructive"
type="button"
onClick={() => void submit()}
disabled={submitting || !isAccountDeletionConfirmation(confirm)}
>
{submitting ? "正在提交" : "确认注销"}
</button>
</Button>
</div>
</div>
)}
@@ -2,7 +2,19 @@
import { Settings, UserRound, WalletCards, X } from "lucide-react";
import { memo, type MutableRefObject, type ReactNode } from "react";
import { createPortal } from "react-dom";
import {
AlertDialog,
AlertDialogContent,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
export type AccountSettingsDialog = "profile" | "chart-library" | "billing" | "general";
@@ -33,10 +45,7 @@ const settingsItems: ReadonlyArray<{
/**
* Panes whose body is a single column of form fields. They get a reading-width cap
* inside the content box; the list panes (星盘资料 / 账户与点数) stay full-bleed.
* The cap is purely inside `.settings-dialog-content`: every pane still renders under
* the same `.settings-modal` box, so the dialog cannot change size between panes
* (BUG-554 / BUG-698).
* inside the content box; the list pane (账户与点数) stays full-bleed.
*/
const formPanes: ReadonlyArray<AccountSettingsDialog> = ["profile", "general"];
@@ -51,70 +60,76 @@ export const AccountDialogOverlay = memo(function AccountDialogOverlay({
}>) {
if (!open || dialog === null || model === null) return null;
const isSettingsDialog = dialog !== "logout";
const isFormPane = dialog !== "logout" && formPanes.includes(dialog);
if (dialog === "logout") {
return (
<AlertDialog open onOpenChange={(next) => { if (!next && !model.signingOut) model.close(); }}>
<AlertDialogContent
className={`account-modal ${model.dialogClass}`}
ref={(node) => { model.overlayRef.current = node; }}
aria-labelledby="account-dialog-title"
>
<header className="account-modal-header">
<AlertDialogTitle id="account-dialog-title">{model.title}</AlertDialogTitle>
</header>
{model.renderLogout()}
</AlertDialogContent>
</AlertDialog>
);
}
const isFormPane = formPanes.includes(dialog);
const renderSettingsContent = () => {
if (dialog === "profile") return model.renderProfile();
if (dialog === "chart-library") return null;
if (dialog === "chart-library") return model.renderChartLibrary();
if (dialog === "billing") return model.renderBilling();
return model.renderGeneral();
};
const overlay = (
<div className="account-modal-overlay" onMouseDown={model.close}>
<section
className={`account-modal ${model.dialogClass}`}
ref={model.overlayRef}
role="dialog"
aria-modal="true"
return (
<Dialog open onOpenChange={(next) => { if (!next) model.close(); }}>
<DialogContent
showCloseButton={false}
className={`account-modal ${model.dialogClass} settings-modal max-w-none sm:max-w-none`}
ref={(node) => { model.overlayRef.current = node; }}
aria-labelledby="account-dialog-title"
onMouseDown={(event) => event.stopPropagation()}
>
<header className="account-modal-header">
<div className="account-modal-heading">
{isSettingsDialog ? <span className="account-modal-eyebrow">设置</span> : null}
<h2 id="account-dialog-title">{model.title}</h2>
<span className="account-modal-eyebrow">设置</span>
<DialogTitle id="account-dialog-title">{model.title}</DialogTitle>
</div>
<button
<Button
className="dialog-close"
ref={model.closeButtonRef}
aria-label="关闭"
type="button"
variant="ghost"
size="icon"
onClick={model.close}
disabled={model.signingOut}
>
<X aria-hidden="true" />
</button>
</Button>
</header>
{isSettingsDialog ? (
<div className="account-settings-shell">
<nav className="settings-dialog-nav" aria-label="设置分区">
{settingsItems.map(({ dialog: itemDialog, label, icon: Icon }) => (
<button
className="settings-dialog-nav-item"
key={itemDialog}
type="button"
aria-current={dialog === itemDialog ? "page" : undefined}
onClick={() => model.navigate(itemDialog)}
>
<Icon aria-hidden="true" />
<span>{label}</span>
</button>
))}
</nav>
<div
className={`settings-dialog-content${isFormPane ? " settings-dialog-content--form" : ""}`}
>
{renderSettingsContent()}
</div>
<Tabs className="account-settings-shell" value={dialog} onValueChange={(next) => model.navigate(next as AccountSettingsDialog)}>
<TabsList className="settings-dialog-nav" aria-label="设置分区">
{settingsItems.map(({ dialog: itemDialog, label, icon: Icon }) => (
<TabsTrigger
className="settings-dialog-nav-item"
key={itemDialog}
value={itemDialog}
aria-current={dialog === itemDialog ? "page" : undefined}
>
<Icon aria-hidden="true" />
<span>{label}</span>
</TabsTrigger>
))}
</TabsList>
<div className={`settings-dialog-content${isFormPane ? " settings-dialog-content--form" : ""}`}>
{renderSettingsContent()}
</div>
) : model.renderLogout()}
</section>
</div>
</Tabs>
</DialogContent>
</Dialog>
);
// BUG-968: SidebarInset is inert while the dialog is open. Portal to body so
// the dialog is not inside that subtree. SSR keeps the in-tree markup so
// renderToString contracts still see the dialog.
if (typeof document === "undefined") return overlay;
return createPortal(overlay, document.body);
});
+118 -71
View File
@@ -1,7 +1,15 @@
"use client";
import { FeedbackDialog } from "@/components/feedback-dialog";
import { Menu } from "@base-ui/react/menu";
import { SidebarCurrentHighlight } from "@/components/shell-motion";
import { useSortableSessions } from "@/components/sidebar-sortable-loader";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/dropdown-menu-lazy";
import {
CalendarDays,
ChevronRight,
@@ -161,6 +169,7 @@ export function AppSidebar({
const [feedbackOpen, setFeedbackOpen] = useState(false);
const accountMenuOpen = controls ? controlledMenuOpen : ownMenuOpen;
const onAccountMenuOpenChange = controls ? controlledMenuOpenChange : setOwnMenuOpen;
const sortableSessions = useSortableSessions();
const hasMoreSessions = sessionControls?.hasMore ?? false;
const onLoadMoreSessions = sessionControls?.onLoadMore;
const favoriteSessions = sessions.filter((session) => session.pinned);
@@ -248,6 +257,39 @@ export function AppSidebar({
);
}
function renderSortableGroup(groupSessions: readonly SidebarSession[], indexOffset: number) {
const onReorder = sessionControls?.onReorder;
if (!onReorder || !sortableSessions || !sessionControls || !onSelectSession) {
return groupSessions.map((session, index) => renderSession(session, indexOffset + index));
}
const Group = sortableSessions.SortableSessionGroup;
return (
<Group
sessions={groupSessions}
onReorder={onReorder}
renderRow={(session) => ({
session,
active: session.id === activeSessionId,
opening: session.id === openingSessionId,
disabled: sessionControls.disabled,
menuOpen: sessionControls.menuSessionId === session.id,
onMenuOpenChange: (open: boolean) => sessionControls.onMenuSessionChange(open ? session.id : null),
onSelect: () => {
onSelectSession(session.id);
closeDrawer();
},
onTogglePinned: () => sessionControls.onTogglePinned(session.id),
onRename: () => sessionControls.onRename(session.id),
onShare: () => sessionControls.onShare(session.id),
onDelete: () => sessionControls.onDelete(session.id),
rowRef: indexOffset + groupSessions.findIndex((item) => item.id === session.id) === 0
? (element: HTMLButtonElement | null) => { firstSessionRef.current = element; }
: undefined,
})}
/>
);
}
/* One entry of the account menu: on `/` the dialog opens in place; on
another page the entry is a link to `/?account=<dialog>` (S2). */
function accountEntry(
@@ -258,16 +300,16 @@ export function AppSidebar({
pointer: { onPointerEnter?: () => void; onPointerDown?: () => void } = {},
) {
return onOpen ? (
<Menu.Item className={className} onClick={onOpen} {...pointer}>{content}</Menu.Item>
<DropdownMenuItem className={className} onClick={onOpen} {...pointer}>{content}</DropdownMenuItem>
) : (
<Menu.LinkItem
<DropdownMenuItem
className={className}
closeOnClick
render={<AppLink href={accountDialogHref(dialog)} onClick={leaveChat} />}
{...pointer}
>
{content}
</Menu.LinkItem>
</DropdownMenuItem>
);
}
@@ -282,28 +324,34 @@ export function AppSidebar({
<SidebarMenuItem>
{controls ? (
<SidebarMenuButton
className="new-chat"
className="sidebar-nav-link"
data-nav="new"
type="button"
tooltip="新对话"
isActive={pathname === "/"}
disabled={newChatDisabled}
onClick={handleNewChat}
>
{pathname === "/" ? <SidebarCurrentHighlight /> : null}
<SquarePen size={18} strokeWidth={1.75} aria-hidden="true" />
{showExpandedContent ? <span>{creatingSession ? "正在创建" : "新建对话"}</span> : null}
</SidebarMenuButton>
) : (
<SidebarMenuLink className="new-chat" tooltip="新对话" href={newChatHref()} onClick={handleNewChatLink}>
<SidebarMenuLink className="sidebar-nav-link" data-nav="new" tooltip="新对话" isActive={pathname === "/"} href={newChatHref()} onClick={handleNewChatLink}>
{pathname === "/" ? <SidebarCurrentHighlight /> : null}
<SquarePen size={18} strokeWidth={1.75} aria-hidden="true" />
{showExpandedContent ? <span>新建对话</span> : null}
</SidebarMenuLink>
)}
</SidebarMenuItem>
{NAV_PAGES.filter((page) => page.href !== "/synastry" || compatibilityEnabled).map(({ href, label, icon: Icon }) => (
<SidebarMenuItem key={href}>
{NAV_PAGES.filter((page) => page.href !== "/synastry" || compatibilityEnabled).map(({ href, label, icon: Icon }) => {
const active = pathname === href || pathname.startsWith(`${href}/`);
return (
<SidebarMenuItem key={href} className="sidebar-nav-item">
<SidebarMenuLink
className="report-nav-button"
className="sidebar-nav-link"
tooltip={label}
isActive={pathname === href || pathname.startsWith(`${href}/`)}
isActive={active}
href={href}
onPointerEnter={() => prefetchSecondaryPage(href)}
onPointerDown={() => prefetchSecondaryPage(href)}
@@ -311,12 +359,14 @@ export function AppSidebar({
{...(href === "/chart" ? { "data-onboarding-anchor": "sidebar-chart" } : {})}
{...(href === "/ephemeris" ? { "data-onboarding-anchor": "ephemeris" } : {})}
>
{active ? <SidebarCurrentHighlight /> : null}
<Icon size={18} strokeWidth={1.75} aria-hidden="true" />
{showExpandedContent ? <span>{label}</span> : null}
{href === "/chart" && showChartDot ? <OnboardingDot /> : null}
</SidebarMenuLink>
</SidebarMenuItem>
))}
);
})}
</SidebarMenu>
</SidebarHeader>
@@ -345,18 +395,20 @@ export function AppSidebar({
<Star size={13} strokeWidth={1.75} aria-hidden="true" />
收藏
</p>
{favoriteSessions.map((session, index) => renderSession(session, index))}
{renderSortableGroup(favoriteSessions, 0)}
</div>
) : null}
{historyGroups.map((group) => (
{historyGroups.map((group) => {
const earlier = historyGroups
.slice(0, historyGroups.indexOf(group))
.reduce((count, item) => count + item.sessions.length, 0);
return (
<div key={group.key}>
<p className="sidebar-group-label">{group.label}</p>
{group.sessions.map((session) => renderSession(
session,
favoriteSessions.length + historySessions.findIndex((item) => item.id === session.id),
))}
{renderSortableGroup(group.sessions, favoriteSessions.length + earlier)}
</div>
))}
);
})}
{hasMoreSessions ? <div ref={loadMoreRef} aria-hidden className="session-list-sentinel" /> : null}
</SidebarMenu>
)}
@@ -387,9 +439,9 @@ export function AppSidebar({
entries open the dialogs in place; elsewhere it keeps its own open
state and the dialog entries go to `/?account=<dialog>`, where Home
opens that dialog (S2). Theme applies where the reader is. */
<Menu.Root open={accountMenuOpen} onOpenChange={onAccountMenuOpenChange} modal={false}>
<Menu.Trigger
className="profile-trigger"
<DropdownMenu open={accountMenuOpen} onOpenChange={onAccountMenuOpenChange} modal={false}>
<DropdownMenuTrigger
className="sidebar-account-trigger"
ref={accountTriggerRef}
type="button"
>
@@ -397,63 +449,58 @@ export function AppSidebar({
? <UserAvatar avatar={account.avatar} size={32} className="profile-avatar" />
: <span className="profile-initial" aria-hidden="true">{account.initial}</span>}
{showExpandedContent ? <span><b>{account.name}</b></span> : null}
{showExpandedContent ? <ChevronRight className={accountMenuOpen ? "chevron is-open" : "chevron"} aria-hidden="true" /> : null}
</Menu.Trigger>
<Menu.Portal>
<Menu.Positioner
side={isMobile || state === "expanded" ? "top" : "right"}
align={isMobile || state === "expanded" ? "end" : "center"}
sideOffset={8}
collisionPadding={12}
>
<Menu.Popup className="account-menu-popup" aria-label="账户菜单">
<div className="account-menu-identity">
{account.avatar
? <UserAvatar avatar={account.avatar} size={40} className="account-menu-avatar" />
: <span className="account-menu-avatar" aria-hidden="true">{account.initial}</span>}
<span><b>{account.name}</b><small>{account.email}</small></span>
</div>
{accountEntry("profile", onOpenProfile, "account-menu-item", (
<><UserRound aria-hidden="true" /><span>个人资料</span><ChevronRight aria-hidden="true" /></>
))}
{accountEntry("general", onOpenGeneral, "account-menu-item", (
<><Settings aria-hidden="true" /><span>通用设置</span><ChevronRight aria-hidden="true" /></>
))}
{accountEntry("billing", onOpenBilling, "account-menu-item", (
<><WalletCards aria-hidden="true" /><span>账户与点数</span><small>{account.credits} 点</small></>
), {
onPointerEnter: () => { prefetchBillingPanel(); prefetchPaymentPackages(); },
onPointerDown: () => { prefetchBillingPanel(); prefetchPaymentPackages(); },
})}
<Menu.Item className="account-menu-item" onClick={() => { onAccountMenuOpenChange?.(false); setFeedbackOpen(true); }}>
<MessageSquareWarning aria-hidden="true" /><span>反馈与投诉</span>
</Menu.Item>
<Menu.Separator className="account-menu-separator" />
<ThemePreferenceMenu />
<Menu.Separator className="account-menu-separator" />
{accountEntry("logout", onOpenLogout, "account-menu-item account-menu-danger", (
<><LogOut aria-hidden="true" /><span>退出登录</span></>
))}
{/* The documents the user agreed to, one tap away (2026-09-30). */}
<div className="account-menu-legal">
<Menu.LinkItem href="/terms" target="_blank" rel="noopener">用户协议</Menu.LinkItem>
<span aria-hidden="true">·</span>
<Menu.LinkItem href="/privacy" target="_blank" rel="noopener">隐私政策</Menu.LinkItem>
</div>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
</DropdownMenuTrigger>
<DropdownMenuContent
className="account-menu-popup w-auto min-w-56"
aria-label="账户菜单"
side={isMobile || state === "expanded" ? "top" : "right"}
align={isMobile || state === "expanded" ? "end" : "center"}
sideOffset={8}
>
<div className="account-menu-identity">
{account.avatar
? <UserAvatar avatar={account.avatar} size={40} className="account-menu-avatar" />
: <span className="account-menu-avatar" aria-hidden="true">{account.initial}</span>}
<span><b>{account.name}</b><small>{account.email}</small></span>
</div>
{accountEntry("profile", onOpenProfile, "account-menu-item", (
<><UserRound aria-hidden="true" /><span>个人资料</span><ChevronRight aria-hidden="true" /></>
))}
{accountEntry("general", onOpenGeneral, "account-menu-item", (
<><Settings aria-hidden="true" /><span>通用设置</span><ChevronRight aria-hidden="true" /></>
))}
{accountEntry("billing", onOpenBilling, "account-menu-item", (
<><WalletCards aria-hidden="true" /><span>账户与点数</span></>
), {
onPointerEnter: () => { prefetchBillingPanel(); prefetchPaymentPackages(); },
onPointerDown: () => { prefetchBillingPanel(); prefetchPaymentPackages(); },
})}
<DropdownMenuItem className="account-menu-item" onClick={() => { onAccountMenuOpenChange?.(false); setFeedbackOpen(true); }}>
<MessageSquareWarning aria-hidden="true" /><span>反馈与投诉</span>
</DropdownMenuItem>
<DropdownMenuSeparator className="account-menu-separator" />
<ThemePreferenceMenu />
<DropdownMenuSeparator className="account-menu-separator" />
{accountEntry("logout", onOpenLogout, "account-menu-item account-menu-danger", (
<><LogOut aria-hidden="true" /><span>退出登录</span></>
))}
<div className="account-menu-legal">
<DropdownMenuItem render={<a href="/terms" target="_blank" rel="noopener" />}>用户协议</DropdownMenuItem>
<span aria-hidden="true">·</span>
<DropdownMenuItem render={<a href="/privacy" target="_blank" rel="noopener" />}>隐私政策</DropdownMenuItem>
</div>
</DropdownMenuContent>
</DropdownMenu>
) : signedOut ? (
/* `/login` crosses an auth boundary, so it stays a document load. */
<a className="profile-trigger" href="/login" onClick={leaveChat}>
<a className="sidebar-account-trigger" href="/login" onClick={leaveChat}>
<span className="profile-initial" aria-hidden="true">·</span>
{showExpandedContent ? <span><b>去登录</b></span> : null}
</a>
) : (
/* The account is still being read: the same 56px block, inert, with
/* The account is still being read: the same block, inert, with
no link and no spinner. It becomes the menu as soon as it lands. */
<div className="profile-trigger" aria-hidden="true">
<div className="sidebar-account-trigger" aria-hidden="true">
<span className="profile-initial">·</span>
</div>
)}
+1 -1
View File
@@ -277,7 +277,7 @@ export function BillingPanel({ account: seedAccount, highlightedPlan, initialTab
<h2 id="membership-orders-title">全部订单</h2>
<p>查看套餐与点数包的支付和到账状态。</p>
</div>
<button className="membership-orders-refresh" type="button" onClick={() => void fetchOrders()}>刷新</button>
<Button className="membership-orders-refresh" type="button" variant="outline" onClick={() => void fetchOrders()}>刷新</Button>
</div>
{ordersState === "unavailable" && <p className="membership-empty">订单记录暂时不可用,请稍后刷新。</p>}
{ordersState === "ready" && orders.length === 0 && <p className="membership-empty">暂无订单。</p>}
@@ -0,0 +1,66 @@
"use client";
import { Coins } from "lucide-react";
import { useEffect, useRef, useState, type MouseEvent } from "react";
import { Button } from "@/components/ui/button";
import { motionPresets } from "@/lib/motion/presets";
export function CreditBalanceButton({
credits,
label,
onClick,
}: {
readonly credits: number | null;
readonly label: string;
readonly onClick: (event: MouseEvent<HTMLButtonElement>) => void;
}) {
return (
<Button variant="outline" className="credit-balance" type="button" aria-label={label} onClick={onClick}>
<Coins className="credit-icon" aria-hidden="true" />
<RollingValue value={credits} />
</Button>
);
}
function RollingValue({ value }: { readonly value: number | null }) {
const [roll, setRoll] = useState({ value, direction: 1 });
const nodeRef = useRef<HTMLSpanElement>(null);
const skipEnter = useRef(true);
if (value !== roll.value) {
const decreased = value !== null && roll.value !== null && value < roll.value;
setRoll({ value, direction: decreased ? -1 : 1 });
}
useEffect(() => {
if (skipEnter.current) {
skipEnter.current = false;
return;
}
const node = nodeRef.current;
const preset = motionPresets.numberChange;
const reduced = typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!node || reduced || preset.reduced.instant) return;
const direction = roll.direction;
void import("motion").then((mod) => {
if (!node.isConnected) return;
void mod.animate(node, { y: [direction * 12, 0] }, {
duration: preset.duration,
ease: [...preset.ease],
});
}, () => undefined);
}, [roll.direction, roll.value]);
return (
<span className="credit-roll">
<span
key={value === null ? "empty" : String(value)}
ref={nodeRef}
className="credit-roll-value"
>
{value === null ? "—" : value}
</span>
</span>
);
}
@@ -0,0 +1,95 @@
"use client";
import { useSyncExternalStore, type ComponentProps } from "react";
import { Button } from "@/components/ui/button";
type MenuModule = typeof import("@/components/ui/dropdown-menu");
let loaded: MenuModule | null = null;
let started = false;
const listeners = new Set<() => void>();
function notify() {
for (const listener of listeners) listener();
}
function subscribe(listener: () => void) {
listeners.add(listener);
if (!started) {
started = true;
void import("@/components/ui/dropdown-menu").then((mod) => {
loaded = mod;
notify();
}, () => {
started = false;
});
}
return () => listeners.delete(listener);
}
function useMenu(): MenuModule | null {
return useSyncExternalStore(subscribe, () => loaded, () => null);
}
export function DropdownMenu(props: ComponentProps<MenuModule["DropdownMenu"]>) {
const menu = useMenu();
if (!menu) return <>{props.children}</>;
return <menu.DropdownMenu {...props} />;
}
export function DropdownMenuTrigger(props: ComponentProps<MenuModule["DropdownMenuTrigger"]>) {
const menu = useMenu();
if (!menu) {
const label = "aria-label" in props && typeof props["aria-label"] === "string" ? props["aria-label"] : undefined;
const className = typeof props.className === "string" ? props.className : undefined;
return (
<Button type="button" variant="ghost" className={className} disabled={Boolean(props.disabled)} aria-label={label}>
{props.children}
</Button>
);
}
return <menu.DropdownMenuTrigger {...props} />;
}
export function DropdownMenuContent(props: ComponentProps<MenuModule["DropdownMenuContent"]>) {
const menu = useMenu();
if (!menu) return null;
return <menu.DropdownMenuContent {...props} />;
}
export function DropdownMenuItem(props: ComponentProps<MenuModule["DropdownMenuItem"]>) {
const menu = useMenu();
if (!menu) return null;
return <menu.DropdownMenuItem {...props} />;
}
export function DropdownMenuSeparator(props: ComponentProps<MenuModule["DropdownMenuSeparator"]>) {
const menu = useMenu();
if (!menu) return null;
return <menu.DropdownMenuSeparator {...props} />;
}
export function DropdownMenuGroup(props: ComponentProps<MenuModule["DropdownMenuGroup"]>) {
const menu = useMenu();
if (!menu) return null;
return <menu.DropdownMenuGroup {...props} />;
}
export function DropdownMenuLabel(props: ComponentProps<MenuModule["DropdownMenuLabel"]>) {
const menu = useMenu();
if (!menu) return null;
return <menu.DropdownMenuLabel {...props} />;
}
export function DropdownMenuRadioGroup(props: ComponentProps<MenuModule["DropdownMenuRadioGroup"]>) {
const menu = useMenu();
if (!menu) return null;
return <menu.DropdownMenuRadioGroup {...props} />;
}
export function DropdownMenuRadioItem(props: ComponentProps<MenuModule["DropdownMenuRadioItem"]>) {
const menu = useMenu();
if (!menu) return null;
return <menu.DropdownMenuRadioItem {...props} />;
}
+44 -38
View File
@@ -1,9 +1,19 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { X } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { LEGAL_ENTITY } from "@/lib/legal-entity";
import {
FEEDBACK_BODY_MAX,
@@ -24,13 +34,11 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
* stores the row for the admin console; the success view promises a reply
* window and gives the contact email.
*/
export function FeedbackDialog({ accountEmail, sessionId, onClose }: Readonly<{
export function FeedbackDialogBody({ accountEmail, sessionId, onClose }: Readonly<{
accountEmail?: string | null;
sessionId?: string | null;
onClose: () => void;
}>) {
const dialog = useRef<HTMLDialogElement>(null);
const titleId = useId();
const [type, setType] = useState<FeedbackType>("problem");
const [body, setBody] = useState("");
const [contact, setContact] = useState(accountEmail ?? "");
@@ -39,16 +47,6 @@ export function FeedbackDialog({ accountEmail, sessionId, onClose }: Readonly<{
const [sent, setSent] = useState(false);
const canAttach = Boolean(sessionId && UUID.test(sessionId));
useEffect(() => {
const element = dialog.current;
const previous = document.activeElement;
element?.showModal();
return () => {
element?.close();
if (previous instanceof HTMLElement && previous.isConnected) previous.focus({ preventScroll: true });
};
}, []);
async function submit() {
const problem = feedbackBodyProblem(body);
if (problem) {
@@ -82,18 +80,7 @@ export function FeedbackDialog({ accountEmail, sessionId, onClose }: Readonly<{
}
return (
<dialog
ref={dialog}
className="feedback-dialog"
aria-labelledby={titleId}
onCancel={(event) => { event.preventDefault(); onClose(); }}
onClick={(event) => { if (event.target === event.currentTarget) onClose(); }}
>
<div className="feedback-dialog-panel">
<header className="feedback-dialog-header">
<h2 id={titleId}>反馈与投诉</h2>
<Button variant="ghost" size="icon" onClick={onClose} aria-label="关闭反馈与投诉" title="关闭"><X aria-hidden="true" /></Button>
</header>
<>
{sent ? (
<div className="feedback-dialog-body feedback-dialog-done" role="status">
<p>已收到,我们会在 {FEEDBACK_RESPONSE_WORKING_DAYS} 个工作日内处理。</p>
@@ -102,16 +89,18 @@ export function FeedbackDialog({ accountEmail, sessionId, onClose }: Readonly<{
</div>
) : (
<form className="feedback-dialog-body" onSubmit={(event) => { event.preventDefault(); void submit(); }}>
<div className="feedback-dialog-types" role="radiogroup" aria-label="反馈类型">
{FEEDBACK_TYPES.map((option) => (
<button key={option} type="button" role="radio" aria-checked={type === option} onClick={() => setType(option)}>
{FEEDBACK_TYPE_LABELS[option]}
</button>
))}
</div>
<Tabs value={type} onValueChange={(next) => setType(next as FeedbackType)}>
<TabsList className="feedback-dialog-types" aria-label="反馈类型">
{FEEDBACK_TYPES.map((option) => (
<TabsTrigger key={option} value={option}>
{FEEDBACK_TYPE_LABELS[option]}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<label className="feedback-dialog-field">
<span>发生了什么</span>
<textarea
<Textarea
value={body}
maxLength={FEEDBACK_BODY_MAX}
rows={5}
@@ -121,18 +110,35 @@ export function FeedbackDialog({ accountEmail, sessionId, onClose }: Readonly<{
</label>
<label className="feedback-dialog-field">
<span>联系方式(选填)</span>
<input value={contact} maxLength={200} onChange={(event) => setContact(event.target.value)} placeholder="邮箱或手机号" />
<Input value={contact} maxLength={200} onChange={(event) => setContact(event.target.value)} placeholder="邮箱或手机号" />
</label>
{canAttach ? (
<label className="feedback-dialog-check">
<input type="checkbox" checked={attach} onChange={(event) => setAttach(event.target.checked)} />
<Checkbox checked={attach} onCheckedChange={(checked) => setAttach(checked === true)} />
<span>附上当前对话(只附编号,便于我们查看)</span>
</label>
) : null}
<Button type="submit" disabled={sending}>{sending ? "正在提交…" : "提交"}</Button>
</form>
)}
</div>
</dialog>
</>
);
}
export function FeedbackDialog(props: Readonly<{
accountEmail?: string | null;
sessionId?: string | null;
onClose: () => void;
}>) {
return (
<Dialog open onOpenChange={(next) => { if (!next) props.onClose(); }}>
<DialogContent className="feedback-dialog sm:max-w-md" showCloseButton={false}>
<DialogHeader className="feedback-dialog-header">
<DialogTitle>反馈与投诉</DialogTitle>
<Button variant="ghost" size="icon" onClick={props.onClose} aria-label="关闭反馈与投诉" title="关闭"><X aria-hidden="true" /></Button>
</DialogHeader>
<FeedbackDialogBody {...props} />
</DialogContent>
</Dialog>
);
}
+10 -7
View File
@@ -1,5 +1,6 @@
"use client";
import { Button } from "@/components/ui/button";
import { UserAvatar } from "@/components/user-avatar";
import { beamAvatarPalettes, type BeamAvatarPatch } from "@/lib/beam-avatar";
import type { Account, Profile } from "@/lib/home-types";
@@ -23,17 +24,18 @@ export function ProfilePanel({
<section className="sheet-section avatar-section" aria-labelledby="avatar-section-title">
<div className="section-heading">
<b id="avatar-section-title">账户头像</b>
<small>Beam 形象会在刷新和换设备后保持一致</small>
</div>
<div className="avatar-editor">
<UserAvatar avatar={account.avatar} size={56} label="当前头像预览" />
<div className="avatar-editor-controls">
<div className="avatar-palette-list" role="radiogroup" aria-label="头像配色">
{beamAvatarPalettes.map((palette, index) => (
<button
<Button
key={palette.name}
className="avatar-palette"
type="button"
variant="ghost"
size="icon"
role="radio"
aria-checked={account.avatar?.palette === index}
aria-label={palette.name}
@@ -42,23 +44,24 @@ export function ProfilePanel({
onClick={() => void persistAvatar({ action: "set_palette", palette: index })}
>
{palette.colors.map((color) => <span key={color} style={{ backgroundColor: color }} />)}
</button>
</Button>
))}
</div>
<button
className="button-secondary avatar-randomize"
<Button
className="avatar-randomize"
type="button"
variant="secondary"
disabled={avatarSaving}
onClick={() => void persistAvatar({ action: "randomize" })}
>
{avatarSaving ? "保存中" : "换一个形象"}
</button>
</Button>
</div>
</div>
</section>
)}
<section className="sheet-section" aria-labelledby="account-info-title">
<div className="section-heading"><b id="account-info-title">账户信息</b><small>登录与账户识别信息</small></div>
<div className="section-heading"><b id="account-info-title">账户信息</b></div>
<dl className="account-info-list">
<div><dt>昵称</dt><dd>{profile.name.trim() || "尚未设置"}<small>与“我的星盘”名称同步</small></dd></div>
<div><dt>登录邮箱</dt><dd>{account.user.email || "尚未读取邮箱"}</dd></div>
+136
View File
@@ -0,0 +1,136 @@
"use client";
import { useEffect, useRef, useSyncExternalStore, type ReactNode } from "react";
import type { PanelImperativeHandle } from "react-resizable-panels";
import { useSidebar } from "@/components/ui/sidebar";
import {
SIDEBAR_WIDTH_COLLAPSED,
SIDEBAR_WIDTH_DEFAULT,
SIDEBAR_WIDTH_MAX,
SIDEBAR_WIDTH_MIN,
SIDEBAR_WIDTH_TABLET,
} from "@/lib/sidebar-state";
import { useUiStore } from "@/stores/ui-store";
type ResizableModule = typeof import("@/components/ui/resizable");
let panels: ResizableModule | null = null;
let started = false;
const listeners = new Set<() => void>();
function notify() {
for (const listener of listeners) listener();
}
function subscribe(listener: () => void) {
listeners.add(listener);
if (!started) {
started = true;
void import("@/components/ui/resizable").then((mod) => {
panels = mod;
notify();
}).catch(() => {
started = false;
});
}
return () => listeners.delete(listener);
}
function useResizablePanels(enabled: boolean): ResizableModule | null {
return useSyncExternalStore(
(listener) => (enabled ? subscribe(listener) : () => {}),
() => (enabled ? panels : null),
() => null,
);
}
export function ShellFrame({ sidebar, children }: { sidebar: ReactNode; children: ReactNode }) {
const { isMobile, open, setOpen, viewport, ready } = useSidebar();
const width = useUiStore((state) => state.sidebarWidth);
const hydrated = useUiStore((state) => state.sidebarWidthHydrated);
const hydrate = useUiStore((state) => state.hydrateSidebarWidth);
const setSidebarWidth = useUiStore((state) => state.setSidebarWidth);
const desktop = ready && !isMobile;
const Resizable = useResizablePanels(desktop && hydrated);
const panelRef = useRef<PanelImperativeHandle | null>(null);
useEffect(() => {
hydrate();
}, [hydrate]);
useEffect(() => {
const panel = panelRef.current;
if (!panel || !Resizable) return;
if (open && panel.isCollapsed()) panel.expand();
if (!open && !panel.isCollapsed()) panel.collapse();
}, [open, Resizable]);
if (!desktop) {
return (
<div className="shell-frame" data-shell-resize="mobile">
{sidebar}
{children}
</div>
);
}
const expandedWidth = hydrated
? width
: viewport === "tablet" ? SIDEBAR_WIDTH_TABLET : SIDEBAR_WIDTH_DEFAULT;
const pixelWidth = open ? expandedWidth : SIDEBAR_WIDTH_COLLAPSED;
if (!Resizable) {
return (
<div
className="shell-frame"
data-shell-resize="pending"
style={{ ["--shell-sidebar-width" as string]: `${pixelWidth}px` }}
>
{sidebar}
{children}
</div>
);
}
function onLayoutChanged(_layout: Record<string, number>, meta: { isUserInteraction: boolean }) {
if (!meta.isUserInteraction) return;
const panel = panelRef.current;
if (!panel) return;
const pixels = panel.getSize().inPixels;
if (panel.isCollapsed() || pixels < 120) {
setOpen(false);
return;
}
setOpen(true);
setSidebarWidth(Math.round(pixels));
}
return (
<Resizable.ResizablePanelGroup
orientation="horizontal"
className="shell-frame"
data-shell-resize="live"
onLayoutChanged={onLayoutChanged}
>
<Resizable.ResizablePanel
id="app-sidebar"
className="shell-sidebar-panel"
panelRef={panelRef}
collapsible
collapsedSize={SIDEBAR_WIDTH_COLLAPSED}
collapsedThreshold={SIDEBAR_WIDTH_MIN - SIDEBAR_WIDTH_COLLAPSED}
minSize={SIDEBAR_WIDTH_MIN}
maxSize={SIDEBAR_WIDTH_MAX}
defaultSize={pixelWidth}
groupResizeBehavior="preserve-pixel-size"
>
{sidebar}
</Resizable.ResizablePanel>
<Resizable.ResizableHandle className="shell-resize-handle" />
<Resizable.ResizablePanel id="app-main" defaultSize="100%">
{children}
</Resizable.ResizablePanel>
</Resizable.ResizablePanelGroup>
);
}
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { usePathname } from "next/navigation";
import { useEffect, useState, type ReactNode } from "react";
import { loadDomAnimation, loadMotionReact, peekMotionReact, type MotionReact } from "@/lib/motion/load-react";
import { motionPresets } from "@/lib/motion/presets";
const NAV = ["/", "/chart", "/ephemeris", "/reports", "/synastry", "/people"] as const;
function pageNavIndex(pathname: string): number {
if (pathname === "/") return 0;
const index = NAV.findIndex((href) => href !== "/" && (pathname === href || pathname.startsWith(`${href}/`)));
return index === -1 ? 0 : index;
}
function useMotionReact(): MotionReact | null {
const [mod, setMod] = useState<MotionReact | null>(() => peekMotionReact());
useEffect(() => {
if (mod) return;
let live = true;
void loadMotionReact().then((next) => {
if (live) setMod(next);
}, () => undefined);
return () => {
live = false;
};
}, [mod]);
return mod;
}
/** Current-page wash. `layoutId` slides it once Motion has arrived. */
export function SidebarCurrentHighlight() {
const motion = useMotionReact();
if (!motion) return <span className="sidebar-nav-current" />;
return <SlidingHighlight motion={motion} />;
}
function SlidingHighlight({ motion }: { readonly motion: MotionReact }) {
const reduced = motion.useReducedMotion();
const preset = motionPresets.selectionFollow;
return (
<motion.LazyMotion features={loadDomAnimation} strict>
<motion.m.span
layoutId="sidebar-current"
className="sidebar-nav-current"
transition={{
duration: reduced ? preset.reduced.duration : preset.duration,
ease: [...preset.ease],
}}
/>
</motion.LazyMotion>
);
}
/**
* Page switch sits on the main pane grid. The grid element stays a plain div
* until the first navigation after Motion loads, so the open page is not
* remounted when the chunk arrives. Routing is not delayed.
*/
export function PageSwitch({ className, children }: { className: string; children: ReactNode }) {
const pathname = usePathname() ?? "/";
const motion = useMotionReact();
const [frame, setFrame] = useState(() => ({ pathname, direction: 1, animated: false }));
if (pathname !== frame.pathname) {
const direction = pageNavIndex(pathname) >= pageNavIndex(frame.pathname) ? 1 : -1;
setFrame({ pathname, direction, animated: motion !== null });
}
if (!frame.animated || motion === null) {
return <div className={`${className} shell-page`}>{children}</div>;
}
return (
<PagePresence motion={motion} className={className} pathname={frame.pathname} direction={frame.direction}>
{children}
</PagePresence>
);
}
function PagePresence({
motion,
className,
pathname,
direction,
children,
}: {
readonly motion: MotionReact;
readonly className: string;
readonly pathname: string;
readonly direction: number;
readonly children: ReactNode;
}) {
const reduced = motion.useReducedMotion();
const enter = motionPresets.pageEnter;
const exit = motionPresets.pageExit;
const distance = reduced ? 0 : enter.distance;
const enterDuration = reduced ? enter.reduced.duration : enter.duration;
const exitDuration = reduced ? exit.reduced.duration : exit.duration;
return (
<motion.LazyMotion features={loadDomAnimation} strict>
<motion.MotionConfig reducedMotion="user">
<div className="shell-page-presence">
<motion.AnimatePresence initial={false} mode="popLayout">
<motion.m.div
key={pathname}
className={`${className} shell-page`}
initial={{ opacity: 0, y: direction * distance }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, transition: { duration: exitDuration, ease: [...exit.ease] } }}
transition={{ duration: enterDuration, ease: [...enter.ease] }}
>
{children}
</motion.m.div>
</motion.AnimatePresence>
</div>
</motion.MotionConfig>
</motion.LazyMotion>
);
}
+30 -28
View File
@@ -1,6 +1,5 @@
"use client";
import { Menu } from "@base-ui/react/menu";
import {
MoreHorizontal,
Pencil,
@@ -9,8 +8,15 @@ import {
StarOff,
Trash2,
} from "lucide-react";
import { forwardRef } from "react";
import { forwardRef, type ReactNode } from "react";
import { AppLink } from "@/components/app-link";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/dropdown-menu-lazy";
import { RECTIFICATION_SIDEBAR_OPENING_NOTE } from "@/lib/rectification-surface-state";
import { SidebarMenuButton } from "@/components/ui/sidebar";
import { sessionMutationMenuVisible } from "@/lib/chat-session-persistence";
@@ -33,6 +39,7 @@ export type SidebarSessionControls = {
readonly onRename: (sessionId: string) => void;
readonly onShare: (sessionId: string) => void;
readonly onDelete: (sessionId: string) => void;
readonly onReorder?: (orderedIds: readonly string[]) => void;
};
type SidebarSessionRowCommonProps = {
@@ -52,15 +59,12 @@ type SidebarSessionRowInteractiveProps = SidebarSessionRowCommonProps & {
readonly onRename: () => void;
readonly onShare: () => void;
readonly onDelete: () => void;
readonly dragHandle?: ReactNode;
};
/**
* Read-only mode: the row is a plain link to `/?c=<id>` and nothing else.
*
* Same `.session-row > .session-main` markup as the interactive row, because a
* second set of markup is exactly how the secondary pages ended up with rows
* that had no 44px minimum, no padding and no current-item marker while still
* reserving a 44px column for a menu button that was never rendered.
* Same row markup as the interactive row, without the menu column.
*/
type SidebarSessionRowLinkProps = SidebarSessionRowCommonProps & {
readonly href: string;
@@ -87,7 +91,7 @@ export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRow
if (props.href !== undefined) {
return (
<div className="session-row" data-readonly="true">
<div className="sidebar-session" data-readonly="true">
<AppLink
className="session-main"
data-sidebar="menu-button"
@@ -112,22 +116,24 @@ export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRow
onRename,
onShare,
onDelete,
dragHandle,
} = props;
return (
<Menu.Root
<DropdownMenu
open={sessionMutationMenuVisible(menuOpen, disabled)}
onOpenChange={onMenuOpenChange}
modal={false}
disabled={disabled}
>
<div
className="session-row"
className="sidebar-session"
data-sortable={dragHandle ? "true" : undefined}
onContextMenu={(event) => {
event.preventDefault();
if (!disabled) onMenuOpenChange(true);
}}
>
{dragHandle}
<SidebarMenuButton
ref={ref}
className="session-main"
@@ -140,29 +146,25 @@ export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRow
>
<SessionRowBody session={session} opening={opening} />
</SidebarMenuButton>
<Menu.Trigger
<DropdownMenuTrigger
className="session-menu-trigger"
type="button"
aria-label={`${session.title} 更多操作`}
disabled={disabled}
>
<MoreHorizontal aria-hidden="true" />
</Menu.Trigger>
</DropdownMenuTrigger>
</div>
<Menu.Portal>
<Menu.Positioner className="session-actions-positioner" side="bottom" align="end" sideOffset={4} collisionPadding={12}>
<Menu.Popup className="session-actions" aria-label={`${session.title} 操作`}>
<Menu.Item className="session-action-item" onClick={onTogglePinned}>
{session.pinned ? <StarOff aria-hidden="true" /> : <Star aria-hidden="true" />}
<span>{session.pinned ? "取消收藏" : "收藏"}</span>
</Menu.Item>
<Menu.Item className="session-action-item" onClick={onRename}><Pencil aria-hidden="true" /><span>重命名</span></Menu.Item>
<Menu.Item className="session-action-item" onClick={onShare}><Share2 aria-hidden="true" /><span>转发</span></Menu.Item>
<Menu.Separator className="session-actions-separator" />
<Menu.Item className="session-action-item session-action-danger" onClick={onDelete}><Trash2 aria-hidden="true" /><span>删除</span></Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
<DropdownMenuContent className="sidebar-session-menu w-auto min-w-44" align="end" side="bottom" aria-label={`${session.title} 操作`}>
<DropdownMenuItem className="session-action-item" onClick={onTogglePinned}>
{session.pinned ? <StarOff aria-hidden="true" /> : <Star aria-hidden="true" />}
<span>{session.pinned ? "取消收藏" : "收藏"}</span>
</DropdownMenuItem>
<DropdownMenuItem className="session-action-item" onClick={onRename}><Pencil aria-hidden="true" /><span>重命名</span></DropdownMenuItem>
<DropdownMenuItem className="session-action-item" onClick={onShare}><Share2 aria-hidden="true" /><span>转发</span></DropdownMenuItem>
<DropdownMenuSeparator className="session-actions-separator" />
<DropdownMenuItem className="session-action-item session-action-danger" onClick={onDelete}><Trash2 aria-hidden="true" /><span>删除</span></DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
});
@@ -0,0 +1,131 @@
"use client";
import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SidebarMenuItem } from "@/components/ui/sidebar";
import {
SidebarSessionRow,
type SidebarSession,
} from "@/components/sidebar-session-row";
import { moveSessionIds } from "@/lib/session-groups";
type RowProps = {
readonly session: SidebarSession;
readonly active: boolean;
readonly opening: boolean;
readonly disabled: boolean;
readonly menuOpen: boolean;
readonly onMenuOpenChange: (open: boolean) => void;
readonly onSelect: () => void;
readonly onTogglePinned: () => void;
readonly onRename: () => void;
readonly onShare: () => void;
readonly onDelete: () => void;
readonly rowRef?: (element: HTMLButtonElement | null) => void;
};
function SortableSessionRow({
session,
active,
opening,
disabled,
menuOpen,
onMenuOpenChange,
onSelect,
onTogglePinned,
onRename,
onShare,
onDelete,
rowRef,
}: RowProps) {
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition } = useSortable({
id: session.id,
});
return (
<SidebarMenuItem
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
>
<SidebarSessionRow
ref={rowRef}
session={session}
active={active}
opening={opening}
disabled={disabled}
menuOpen={menuOpen}
onMenuOpenChange={onMenuOpenChange}
onSelect={onSelect}
onTogglePinned={onTogglePinned}
onRename={onRename}
onShare={onShare}
onDelete={onDelete}
dragHandle={(
<Button
type="button"
variant="ghost"
size="icon-sm"
className="session-drag-handle"
aria-label={`${session.title} 拖动排序`}
disabled={disabled}
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
>
<GripVertical aria-hidden="true" />
</Button>
)}
/>
</SidebarMenuItem>
);
}
export function SortableSessionGroup({
sessions,
onReorder,
renderRow,
}: {
readonly sessions: readonly SidebarSession[];
readonly onReorder: (orderedIds: readonly string[]) => void;
readonly renderRow: (session: SidebarSession) => RowProps;
}) {
const ids = sessions.map((session) => session.id);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
function onDragEnd(event: DragEndEvent) {
const activeId = String(event.active.id);
const overId = event.over ? String(event.over.id) : "";
if (!overId || activeId === overId) return;
onReorder(moveSessionIds(ids, activeId, overId));
}
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{sessions.map((session) => {
const row = renderRow(session);
return <SortableSessionRow key={session.id} {...row} />;
})}
</SortableContext>
</DndContext>
);
}
@@ -0,0 +1,40 @@
"use client";
import { useSyncExternalStore } from "react";
import type { SortableSessionGroup } from "@/components/sidebar-session-sortable";
type SortableModule = {
SortableSessionGroup: typeof SortableSessionGroup;
};
let loaded: SortableModule | null = null;
let started = false;
const listeners = new Set<() => void>();
function notify() {
for (const listener of listeners) listener();
}
function subscribe(listener: () => void) {
listeners.add(listener);
if (!started) {
started = true;
void import("@/components/sidebar-session-sortable").then((mod) => {
loaded = { SortableSessionGroup: mod.SortableSessionGroup };
notify();
}).catch(() => {
started = false;
});
}
return () => listeners.delete(listener);
}
function snapshot() {
return loaded;
}
/** Client-only. The server snapshot stays null so the row list paints before dnd-kit arrives. */
export function useSortableSessions(): SortableModule | null {
return useSyncExternalStore(subscribe, snapshot, () => null);
}
+57 -55
View File
@@ -1,9 +1,10 @@
"use client";
import { Popover } from "@base-ui/react/popover";
import { Check, ChevronDown } from "lucide-react";
import { Check, ChevronDown, Plus } from "lucide-react";
import { useId, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
import {
chatProfilePickerRows,
type ChatProfileCatalogStatus,
@@ -63,14 +64,14 @@ export function SubjectSwitcher({
}
return (
<Popover.Root
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) triggerRef.current?.focus({ preventScroll: true });
}}
>
<Popover.Trigger
<PopoverTrigger
ref={triggerRef}
className="chat-header-chart chat-profile-picker-trigger"
type="button"
@@ -80,53 +81,52 @@ export function SubjectSwitcher({
>
<span className="chat-profile-picker-name">{label}</span>
<ChevronDown className="chat-profile-picker-chevron" aria-hidden="true" />
</Popover.Trigger>
<Popover.Portal>
<Popover.Positioner className="chat-profile-picker-positioner" side="bottom" align="start" sideOffset={8} collisionPadding={12}>
<Popover.Popup
id={listId}
className="chat-profile-picker-popup"
onKeyDown={(event) => {
if (event.key !== "Escape") return;
event.preventDefault();
closePicker();
}}
>
<Popover.Title className="chat-profile-picker-title">当前星盘</Popover.Title>
{status === "loading" ? <OrbitWaiting className="chat-profile-picker-waiting" label="人物列表还在读" /> : null}
{status === "failed" ? (
<div className="chat-profile-picker-status" role="status">
<p>人物列表暂时读不出来</p>
<button className="chat-profile-picker-retry" type="button" onClick={onReload}>重试</button>
</div>
) : null}
{status === "degraded" ? (
<p className="chat-profile-picker-status" role="status">暂时读不到其他人</p>
) : null}
{deleted ? <p className="chat-profile-picker-status">这份资料已经删除,这段对话只能看。</p> : null}
{rows.length > 0 ? (
<div className="chat-profile-picker-options" role="group" aria-label="当前星盘">
{rows.map((row) => (
<ProfileRow
key={row.id}
row={row}
locked={locked}
requestInFlight={requestInFlight}
pending={pendingId === row.id}
onChoose={() => void choose(row)}
/>
))}
</div>
) : null}
{showAddPerson ? (
<div className="chat-profile-picker-add">
<a href="/people">+ 添加一个人</a>
</div>
) : null}
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
</PopoverTrigger>
<PopoverContent
id={listId}
className="chat-profile-picker-popup"
side="bottom"
align="start"
sideOffset={8}
onKeyDown={(event) => {
if (event.key !== "Escape") return;
event.preventDefault();
closePicker();
}}
>
<PopoverTitle className="chat-profile-picker-title">当前星盘</PopoverTitle>
{status === "loading" ? <OrbitWaiting className="chat-profile-picker-waiting" label="人物列表还在读" /> : null}
{status === "failed" ? (
<div className="chat-profile-picker-status" role="status">
<p>人物列表暂时读不出来</p>
<Button className="chat-profile-picker-retry" type="button" variant="ghost" onClick={onReload}>重试</Button>
</div>
) : null}
{status === "degraded" ? (
<p className="chat-profile-picker-status" role="status">暂时读不到其他人</p>
) : null}
{deleted ? <p className="chat-profile-picker-status">这份资料已经删除,这段对话只能看。</p> : null}
{rows.length > 0 ? (
<div className="chat-profile-picker-options" role="group" aria-label="当前星盘">
{rows.map((row) => (
<ProfileRow
key={row.id}
row={row}
locked={locked}
requestInFlight={requestInFlight}
pending={pendingId === row.id}
onChoose={() => void choose(row)}
/>
))}
</div>
) : null}
{showAddPerson ? (
<div className="chat-profile-picker-add">
<a href="/people"><Plus aria-hidden="true" />添加一个人</a>
</div>
) : null}
</PopoverContent>
</Popover>
);
}
@@ -156,9 +156,10 @@ function ProfileRow({
if (locked && !row.selected) {
return (
<button
<Button
className="chat-profile-picker-option"
type="button"
variant="ghost"
disabled={requestInFlight || pending}
aria-label={`${row.name},新建会话并使用此人物`}
onClick={onChoose}
@@ -167,14 +168,15 @@ function ProfileRow({
<b>{row.name}</b>
<small>{requestInFlight ? "这段回答结束后再换" : "新建会话并使用此人物"}</small>
</span>
</button>
</Button>
);
}
return (
<button
<Button
className="chat-profile-picker-option"
type="button"
variant="ghost"
aria-pressed={row.selected}
data-selected={row.selected ? "" : undefined}
disabled={pending}
@@ -185,6 +187,6 @@ function ProfileRow({
<small>{row.detail}</small>
</span>
<Check className="chat-profile-picker-check" aria-hidden="true" />
</button>
</Button>
);
}
@@ -1,9 +1,15 @@
"use client";
import { Menu } from "@base-ui/react/menu";
import { Check, Monitor, Moon, Sun } from "lucide-react";
import { Monitor, Moon, Sun } from "lucide-react";
import { useSyncExternalStore } from "react";
import {
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
} from "@/components/dropdown-menu-lazy";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
applyThemePreference,
readThemePreference,
@@ -38,23 +44,21 @@ export function ThemePreferencePanel() {
<section className="theme-preference-panel" aria-labelledby="theme-preference-title">
<div className="section-heading">
<b id="theme-preference-title">外观主题</b>
<small>选择 Jyotisha 的显示方式</small>
</div>
<div className="theme-preference-options" role="group" aria-label="外观主题">
{options.map((option) => (
<button
className="theme-preference-option"
key={option.value}
type="button"
aria-pressed={preference === option.value}
onClick={() => applyThemePreference(option.value)}
>
<OptionIcon value={option.value} />
<span>{option.label}</span>
{preference === option.value ? <Check aria-hidden="true" /> : null}
</button>
))}
</div>
<Tabs
className="theme-preference-options"
value={preference}
onValueChange={(next) => applyThemePreference(next as ThemePreference)}
>
<TabsList aria-label="外观主题">
{options.map((option) => (
<TabsTrigger className="theme-preference-option" key={option.value} value={option.value}>
<OptionIcon value={option.value} />
<span>{option.label}</span>
</TabsTrigger>
))}
</TabsList>
</Tabs>
</section>
);
}
@@ -63,16 +67,16 @@ export function ThemePreferenceMenu() {
const preference = useThemePreference();
return (
<Menu.Group className="theme-menu">
<Menu.GroupLabel className="theme-menu-label">外观</Menu.GroupLabel>
<Menu.RadioGroup
<DropdownMenuGroup className="theme-menu">
<DropdownMenuLabel className="theme-menu-label">外观</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={preference}
onValueChange={(next) => {
applyThemePreference(next as ThemePreference);
}}
>
{options.map((option) => (
<Menu.RadioItem
<DropdownMenuRadioItem
className="account-menu-item theme-menu-item"
closeOnClick={false}
key={option.value}
@@ -80,12 +84,9 @@ export function ThemePreferenceMenu() {
>
<OptionIcon value={option.value} />
<span>{option.label}</span>
<Menu.RadioItemIndicator className="theme-menu-check">
<Check aria-hidden="true" />
</Menu.RadioItemIndicator>
</Menu.RadioItem>
</DropdownMenuRadioItem>
))}
</Menu.RadioGroup>
</Menu.Group>
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
);
}
+9 -4
View File
@@ -16,6 +16,7 @@ import {
} from "react";
import { AppLink } from "@/components/app-link";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { useSidebarViewport } from "@/hooks/use-sidebar-viewport";
import {
@@ -210,10 +211,14 @@ export function SidebarProvider({
export function Sidebar({ id, className, ...props }: ComponentProps<"aside">) {
const { isMobile, open, openMobile, setOpenMobile, setSidebarSurface } = useSidebarContext();
const sidebar = <aside id={id ?? "chat-sidebar"} data-sidebar="sidebar" data-slot="sidebar" data-state={open ? "expanded" : "collapsed"} data-mobile-open={openMobile} className={cn("flex min-h-0 shrink-0 flex-col", className)} tabIndex={-1} {...props} ref={setSidebarSurface} />;
return <>
{sidebar}
{isMobile && openMobile ? <button type="button" data-sidebar="scrim" data-slot="sidebar-scrim" aria-label="关闭聊天记录" className="sidebar-scrim" onClick={() => setOpenMobile(false)} /> : null}
</>;
if (!isMobile) return sidebar;
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile}>
<SheetContent side="left" showCloseButton={false} className="sidebar-sheet w-[min(86vw,320px)] max-w-none gap-0 p-0 sm:max-w-none">
{sidebar}
</SheetContent>
</Sheet>
);
}
export function SidebarHeader({ className, ...props }: ComponentProps<"div">) {