feat: replace account sheet with task dialogs
This commit is contained in:
@@ -137,12 +137,7 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.composer-suggestions::-webkit-scrollbar { display: none; }
|
||||
.composer textarea::placeholder { color: var(--color-ink-tertiary); }
|
||||
.composer button svg { width: 19px; height: 19px; }
|
||||
.profile-overlay.is-open { opacity: 1; visibility: visible; transition-delay: 0s; }
|
||||
.profile-overlay.is-open .profile-dialog { transform: translateX(0); }
|
||||
.profile-dialog > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 20px; }
|
||||
.dialog-close svg, .section-toggle > svg { width: 19px; height: 19px; }
|
||||
.account-summary span, .account-summary strong { display: block; }
|
||||
.section-toggle { width: 100%; min-height: 52px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0; border: 0; background: transparent; cursor: pointer; text-align: left; transition: color 120ms ease-out, transform 120ms ease-out; }
|
||||
.dialog-close svg { width: 19px; height: 19px; }
|
||||
.redeem-form { display: grid; gap: 8px; padding-top: 12px; }
|
||||
.redeem-form > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
|
||||
.redeem-form label, .profile-form label > span, .code-form label > span, .stack-form label { color: var(--color-ink-secondary); font-size: 11px; font-weight: 600; }
|
||||
@@ -155,7 +150,6 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.save-profile { justify-self: end; }
|
||||
.form-error { background: var(--color-danger-muted); color: var(--color-danger); }
|
||||
.form-success { background: var(--color-success-muted); color: var(--color-success); }
|
||||
.account-actions { display: flex; justify-content: space-between; gap: 8px; padding-top: 20px; }
|
||||
|
||||
.standalone-page { width: 100%; height: 100dvh; overflow: auto; }
|
||||
.auth-brand strong { font-weight: 600; }
|
||||
@@ -172,17 +166,21 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.status-已过期, .status-已兑换 { color: var(--color-ink-secondary); }
|
||||
.empty-cell { color: var(--color-ink-secondary); text-align: center !important; }
|
||||
|
||||
.new-chat:not(:disabled):active, .session-list button:not(:disabled):active, .profile-trigger:not(:disabled):active, .credit-button:not(:disabled):active, .starter-list button:not(:disabled):active, .composer-suggestions button:not(:disabled):active, .composer button:not(:disabled):active, .button-primary:not(:disabled):active, .button-secondary:not(:disabled):active, .dialog-close:not(:disabled):active, .section-toggle:not(:disabled):active, .generated-list button:not(:disabled):active, .inline-actions button:not(:disabled):active { transform: scale(.98); }
|
||||
.new-chat:not(:disabled):active, .session-list button:not(:disabled):active, .profile-trigger:not(:disabled):active, .credit-button:not(:disabled):active, .account-menu-item:not(:disabled):active, .starter-list button:not(:disabled):active, .composer-suggestions button:not(:disabled):active, .composer button:not(:disabled):active, .button-primary:not(:disabled):active, .button-secondary:not(:disabled):active, .dialog-close:not(:disabled):active, .generated-list button:not(:disabled):active, .inline-actions button:not(:disabled):active { transform: scale(.98); }
|
||||
|
||||
@keyframes app-loading-orbit { to { transform: rotate(360deg); } }
|
||||
@keyframes pulse { from { opacity: .28; transform: translateY(1px); } to { opacity: 1; transform: translateY(-1px); } }
|
||||
@keyframes message-enter { from { opacity: 0; transform: translateY(4px); } }
|
||||
@keyframes onboarding-card-enter { from { opacity: 0; transform: translateY(6px); } }
|
||||
@keyframes onboarding-caret { 50% { opacity: 0; } }
|
||||
@keyframes account-menu-enter { from { opacity: 0; transform: translateY(var(--space-1)); } }
|
||||
@keyframes account-overlay-enter { from { opacity: 0; } }
|
||||
@keyframes account-dialog-enter { from { opacity: 0; transform: translateY(var(--space-1)) scale(.985); } }
|
||||
|
||||
@media (hover: hover) {
|
||||
.session-list button:not(:disabled):hover, .profile-trigger:not(:disabled):hover { background: var(--color-light-hover); color: var(--color-ink); }
|
||||
.credit-button:not(:disabled):hover, .dialog-close:not(:disabled):hover { background: color-mix(in srgb, var(--color-border) 54%, transparent); }
|
||||
.account-menu-item:hover { background: var(--color-canvas-muted); }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@@ -213,9 +211,6 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.message-user p { font-size: 14px; }
|
||||
.composer { min-height: 56px; }
|
||||
.composer-footer > p { display: none; }
|
||||
.profile-overlay { align-items: flex-end; }
|
||||
.profile-overlay.is-open .profile-dialog { transform: translateY(0); }
|
||||
.account-actions { padding-bottom: max(0px, env(safe-area-inset-bottom)); }
|
||||
.generated-section .section-title { align-items: stretch; flex-direction: column; }
|
||||
.generated-section .button-secondary { align-self: flex-start; }
|
||||
}
|
||||
@@ -224,7 +219,6 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.profile-grid, .location-grid, .code-form { grid-template-columns: 1fr; }
|
||||
.code-form .note-field { grid-column: auto; }
|
||||
.code-form .button-primary { width: 100%; }
|
||||
.account-summary { grid-template-columns: minmax(0, 1fr) 96px; }
|
||||
.auth-page { padding: 16px; }
|
||||
.auth-brand { margin-bottom: 32px; }
|
||||
}
|
||||
@@ -269,7 +263,7 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.session-list button.is-active::before { position: absolute; border-radius: 3px; content: ""; top: var(--space-3); bottom: var(--space-3); left: var(--space-1); width: 2px; background: var(--color-action); }
|
||||
.session-list button > span { overflow: hidden; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 500; }
|
||||
.session-list button small { color: inherit; line-height: 1.3; opacity: .72; font-size: 12px; }
|
||||
.sidebar-footer { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--color-border); }
|
||||
.sidebar-footer { position: relative; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--color-border); }
|
||||
.profile-trigger { width: 100%; display: grid; grid-template-columns: 34px minmax(0, 1fr) 18px; align-items: center; gap: 9px; padding: 5px 7px; border: 0; background: transparent; color: var(--color-ink); cursor: pointer; text-align: left; transition: background-color 120ms ease-out, transform 120ms ease-out; min-height: 56px; border-radius: var(--radius-md); }
|
||||
.profile-initial { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 50%; font-size: 12px; text-transform: uppercase; border: 0; background: var(--color-action); color: var(--color-on-dark); font-weight: 500; }
|
||||
.profile-trigger b { display: block; overflow: hidden; line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; }
|
||||
@@ -367,22 +361,37 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.model-selector-trigger { max-width: 100%; grid-column: 1; grid-row: 1; }
|
||||
}
|
||||
|
||||
.profile-overlay { position: fixed; z-index: 20; inset: 0; display: flex; justify-content: flex-end; opacity: 0; visibility: hidden; transition: opacity 180ms ease-out, visibility 0s linear 180ms; background: var(--color-scrim); }
|
||||
.profile-dialog { height: 100dvh; overflow-y: auto; border-left: 1px solid var(--color-border); transform: translateX(24px); transition: transform 180ms var(--ease-out); width: min(560px, 100%); padding: var(--space-8); border-color: var(--color-border); background: var(--color-canvas); box-shadow: var(--shadow-elevated); }
|
||||
.profile-dialog h2, .auth-panel h1, .admin-header h1 { font-family: var(--font-display); font-weight: 400; letter-spacing: -.5px; text-wrap: balance; }
|
||||
.profile-dialog h2 { margin: 0; font-weight: 400; letter-spacing: -.025em; font-size: var(--type-display-sm); }
|
||||
.profile-trigger .chevron { transition: transform 120ms var(--ease-out); }
|
||||
.profile-trigger .chevron.is-open { transform: rotate(-90deg); }
|
||||
.account-menu { position: absolute; z-index: 30; right: 0; bottom: calc(100% + var(--space-2)); width: min(280px, calc(100vw - var(--space-6))); overflow: hidden; padding: var(--space-2); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); box-shadow: var(--shadow-elevated); transform-origin: bottom right; animation: account-menu-enter 120ms var(--ease-out) both; }
|
||||
.account-menu-identity { min-width: 0; display: grid; grid-template-columns: 36px minmax(0, 1fr); align-items: center; gap: var(--space-3); padding: var(--space-2) var(--space-3) var(--space-3); }
|
||||
.account-menu-avatar { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 50%; background: var(--color-action); color: var(--color-on-dark); font-size: var(--type-caption); font-weight: 500; text-transform: uppercase; }
|
||||
.account-menu-identity > span:last-child { min-width: 0; }
|
||||
.account-menu-identity b, .account-menu-identity small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.account-menu-identity b { color: var(--color-ink); font-size: var(--type-body-sm); font-weight: 500; }
|
||||
.account-menu-identity small { margin-top: var(--space-1); color: var(--color-ink-tertiary); font-size: var(--type-overline); }
|
||||
.account-menu-item { width: 100%; min-height: 44px; display: grid; grid-template-columns: 20px minmax(0, 1fr) auto; align-items: center; gap: var(--space-3); padding: 0 var(--space-3); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-ink); cursor: pointer; text-align: left; text-decoration: none; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; }
|
||||
.account-menu-item > svg { width: 18px; height: 18px; color: var(--color-ink-tertiary); }
|
||||
.account-menu-item > svg:last-child { width: 16px; height: 16px; }
|
||||
.account-menu-item > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--type-body-sm); }
|
||||
.account-menu-item > small { color: var(--color-ink-tertiary); font-size: var(--type-caption); font-variant-numeric: tabular-nums; }
|
||||
.account-menu-separator { height: 1px; margin: var(--space-2) var(--space-3); background: var(--color-border); }
|
||||
.account-menu-danger, .account-menu-danger > svg { color: var(--color-danger); }
|
||||
|
||||
.account-modal-overlay { position: fixed; z-index: 40; inset: 0; display: grid; place-items: center; padding: var(--space-4); background: var(--color-scrim); animation: account-overlay-enter 180ms ease-out both; }
|
||||
.account-modal { width: min(100%, 560px); max-height: min(84dvh, 760px); overflow-y: auto; padding: var(--space-8); border: 1px solid var(--color-border); border-radius: var(--radius-xl); background: var(--color-canvas); box-shadow: var(--shadow-elevated); animation: account-dialog-enter 180ms var(--ease-out) both; }
|
||||
.redeem-modal { width: min(100%, 420px); }
|
||||
.logout-modal { width: min(100%, 400px); }
|
||||
.account-modal h2, .auth-panel h1, .admin-header h1 { font-family: var(--font-display); font-weight: 400; letter-spacing: -.5px; text-wrap: balance; }
|
||||
.account-modal h2 { margin: 0; font-size: var(--type-display-sm); letter-spacing: -.025em; }
|
||||
.account-modal-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); padding-bottom: var(--space-5); }
|
||||
.dialog-close { width: 44px; height: 44px; display: grid; flex: 0 0 auto; place-items: center; border: 0; cursor: pointer; transition: background-color 120ms ease-out, transform 120ms ease-out; border-radius: var(--radius-md); background: var(--color-canvas-muted); }
|
||||
.account-summary { display: grid; grid-template-columns: minmax(0, 1fr) 120px; border-top: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border); gap: var(--space-2); padding: var(--space-2) 0; background: transparent; }
|
||||
.account-summary div { min-width: 0; padding: var(--space-4); }
|
||||
.account-summary div + div { padding-left: 18px; border-left: 1px solid var(--color-border); }
|
||||
.account-summary span { margin-bottom: 6px; color: var(--color-ink-tertiary); font-size: 12px; }
|
||||
.account-summary strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-ink); font-size: 14px; font-weight: 500; }
|
||||
.sheet-section { border-bottom: 1px solid var(--color-border); padding: var(--space-6) 0; border-color: var(--color-border); }
|
||||
.section-toggle b, .section-heading b { display: block; font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; }
|
||||
.section-toggle small, .section-heading small { display: block; margin-top: 4px; color: var(--color-ink-secondary); font-weight: 400; font-size: var(--type-caption); }
|
||||
.default-chart-card { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); margin-top: var(--space-5); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-surface-subtle); }
|
||||
.default-chart-card span, .default-chart-card small { display: block; color: var(--color-ink-secondary); font-size: var(--type-caption); }
|
||||
.default-chart-card strong { display: block; margin: 4px 0; color: var(--color-ink); font-size: var(--type-body); font-weight: 500; }
|
||||
.redeem-balance { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-4); padding: var(--space-4) 0; border-top: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border); }
|
||||
.redeem-balance span { color: var(--color-ink-secondary); font-size: var(--type-caption); }
|
||||
.redeem-balance strong { color: var(--color-ink); font-size: var(--type-title-lg); font-variant-numeric: tabular-nums; font-weight: 400; }
|
||||
.account-redeem-form { padding-top: var(--space-5); }
|
||||
.logout-copy { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-body-md); line-height: 1.6; text-wrap: pretty; }
|
||||
.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-6); }
|
||||
|
||||
input, select { width: 100%; min-height: 44px; padding: 0 12px; border: 1px solid var(--color-border-strong); color: var(--color-ink); border-color: var(--color-border-strong); border-radius: var(--radius-md); background: var(--color-canvas); font-size: 14px; }
|
||||
input:disabled, select:disabled { color: var(--color-ink-tertiary); background: var(--color-canvas-muted); }
|
||||
@@ -390,7 +399,7 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
|
||||
.button-primary { border-color: var(--color-action); background: var(--color-action); color: var(--color-on-dark); }
|
||||
.button-secondary { border-color: var(--color-border-strong); background: var(--color-canvas); color: var(--color-ink); }
|
||||
.form-error, .form-success { margin: 10px 0 0; padding: 10px 12px; border-left: 3px solid currentColor; line-height: 1.5; border-radius: 0 var(--radius-md) var(--radius-md) 0; font-size: 13px; }
|
||||
.danger-button { border-color: var(--color-danger); color: var(--color-danger); }
|
||||
.danger-primary { border-color: var(--color-danger); background: var(--color-danger); }
|
||||
|
||||
.auth-page { display: grid; place-items: center; padding: var(--space-8); background: var(--color-canvas-soft); }
|
||||
.auth-shell { width: min(1040px, 100%); min-height: min(680px, calc(100dvh - 64px)); display: grid; grid-template-columns: 1.08fr .92fr; overflow: hidden; border-radius: var(--radius-xl); background: var(--color-canvas); box-shadow: var(--shadow-elevated); }
|
||||
@@ -438,7 +447,7 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
|
||||
.starter-list button:first-child:not(:disabled):hover { background: color-mix(in srgb, var(--color-action-soft) 72%, var(--color-canvas)); }
|
||||
.composer-suggestions button:not(:disabled):hover { border-color: var(--color-action); background: var(--color-canvas); color: var(--color-action-hover); }
|
||||
.button-secondary:not(:disabled):hover { background: var(--color-canvas-muted); }
|
||||
.section-toggle:not(:disabled):hover { color: var(--color-action-hover); }
|
||||
.danger-primary:not(:disabled):hover { background: color-mix(in srgb, var(--color-danger) 88%, var(--color-ink)); }
|
||||
}
|
||||
|
||||
@media (min-width: 768px) and (max-width: 900px) {
|
||||
@@ -465,7 +474,8 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
|
||||
.message-list { width: 100%; padding: var(--space-5) var(--space-4) var(--space-12); }
|
||||
.message-content { max-width: 88%; }
|
||||
.composer-wrap { padding: var(--space-2) var(--space-3) max(var(--space-3), env(safe-area-inset-bottom)); }
|
||||
.profile-dialog { width: 100%; height: min(88dvh, 760px); border-top: 1px solid var(--color-border); border-left: 0; transform: translateY(24px); padding: var(--space-6); border-radius: var(--radius-xl) var(--radius-xl) 0 0; }
|
||||
.account-menu { width: min(280px, calc(100vw - var(--space-6))); }
|
||||
.account-modal { max-height: calc(100dvh - var(--space-8)); padding: var(--space-6); }
|
||||
.auth-page { padding: 0; }
|
||||
.auth-shell { min-height: 100dvh; grid-template-columns: 1fr; grid-template-rows: auto 1fr; border-radius: 0; box-shadow: none; }
|
||||
.auth-story { min-height: 248px; padding: var(--space-8) var(--space-6); }
|
||||
@@ -480,7 +490,10 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
|
||||
@media (max-width: 480px) {
|
||||
.welcome > .onboarding-message:first-child .message-bubble p { font-size: var(--type-title-lg); }
|
||||
.starter-content span { font-size: var(--type-title-sm); }
|
||||
.profile-dialog h2 { font-size: var(--type-display-sm); }
|
||||
.account-modal h2 { font-size: var(--type-title-lg); }
|
||||
.account-redeem-form > div { grid-template-columns: 1fr; }
|
||||
.dialog-actions { flex-direction: column-reverse; }
|
||||
.dialog-actions > button { width: 100%; }
|
||||
.auth-story { min-height: 220px; padding: var(--space-6) var(--space-5); }
|
||||
.auth-story h2 { font-size: var(--type-display-sm); }
|
||||
.auth-panel { padding: var(--space-8) var(--space-5); }
|
||||
|
||||
+152
-86
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowUp, ArrowUpRight, ChevronRight, Menu, Minus, Plus, Sparkles, Square, X } from "lucide-react";
|
||||
import { ArrowUp, ArrowUpRight, ChevronRight, Gift, KeyRound, LogOut, Menu, Plus, Sparkles, Square, UserRound, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { FormEvent, KeyboardEvent } from "react";
|
||||
import { ChatMessageContent } from "@/components/chat-message-content";
|
||||
@@ -42,6 +42,7 @@ type OnboardingSuggestion = { theme: Exclude<Theme, "general">; text: string };
|
||||
type OnboardingContent = { greeting: string; suggestions: OnboardingSuggestion[] };
|
||||
type OnboardingStep = "name" | "birth" | "place";
|
||||
type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening" | "late-night";
|
||||
type AccountDialog = "profile" | "redeem" | "logout";
|
||||
type SessionReadResult = { readonly sessions: ChatSession[]; readonly fallbackSessionIds: string[] };
|
||||
type PendingConsultation = {
|
||||
readonly requestId: string;
|
||||
@@ -65,6 +66,18 @@ const themes: Array<{ id: Exclude<Theme, "general">; label: string; prompt: stri
|
||||
{ id: "timing", label: "时运", prompt: "未来哪些阶段值得把握?" },
|
||||
];
|
||||
|
||||
const accountDialogTitles = {
|
||||
profile: "个人资料",
|
||||
redeem: "兑换点数",
|
||||
logout: "退出登录?",
|
||||
} as const satisfies Record<AccountDialog, string>;
|
||||
|
||||
const accountDialogClasses = {
|
||||
profile: "profile-modal",
|
||||
redeem: "redeem-modal",
|
||||
logout: "logout-modal",
|
||||
} as const satisfies Record<AccountDialog, string>;
|
||||
|
||||
const previewModelCatalog = parsePublicModelCatalog({
|
||||
defaultModelId: "deepseek-pro",
|
||||
models: [
|
||||
@@ -428,12 +441,12 @@ async function fetchModelCatalog(signal?: AbortSignal) {
|
||||
export default function Home() {
|
||||
const [profile, setProfile] = useState<Profile>(emptyProfile);
|
||||
const [profileDraft, setProfileDraft] = useState<Profile>(emptyProfile);
|
||||
const [profileOpen, setProfileOpen] = useState(false);
|
||||
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
|
||||
const [activeAccountDialog, setActiveAccountDialog] = useState<AccountDialog | null>(null);
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
const [profileNotice, setProfileNotice] = useState("");
|
||||
const [account, setAccount] = useState<Account | null>(null);
|
||||
const [accountError, setAccountError] = useState("");
|
||||
const [redeemOpen, setRedeemOpen] = useState(false);
|
||||
const [redeemCode, setRedeemCode] = useState("");
|
||||
const [redeemError, setRedeemError] = useState("");
|
||||
const [redeemMessage, setRedeemMessage] = useState("");
|
||||
@@ -461,10 +474,13 @@ export default function Home() {
|
||||
const [presetMessageLength, setPresetMessageLength] = useState(0);
|
||||
const conversationEnd = useRef<HTMLDivElement>(null);
|
||||
const accountTrigger = useRef<HTMLButtonElement>(null);
|
||||
const accountMenu = useRef<HTMLDivElement>(null);
|
||||
const accountDialog = useRef<HTMLElement>(null);
|
||||
const creditTrigger = useRef<HTMLButtonElement>(null);
|
||||
const dialogReturnTarget = useRef<HTMLButtonElement | null>(null);
|
||||
const mobileMenuTrigger = useRef<HTMLButtonElement>(null);
|
||||
const sidebar = useRef<HTMLElement>(null);
|
||||
const sidebarCloseButton = useRef<HTMLButtonElement>(null);
|
||||
const profileDialog = useRef<HTMLElement>(null);
|
||||
const closeButton = useRef<HTMLButtonElement>(null);
|
||||
const redeemInput = useRef<HTMLInputElement>(null);
|
||||
const composerInput = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -732,16 +748,17 @@ export default function Home() {
|
||||
}, [activeSessionId, activeSession?.messages.length, activeStreamingText, isLoading, onboardingPending, onboardingStep, presetMessageFinished, profileComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hydrated && accountId && !profileComplete && onboardingStep === "name" && presetMessageFinished && !profileOpen) {
|
||||
if (hydrated && accountId && !profileComplete && onboardingStep === "name" && presetMessageFinished && activeAccountDialog === null) {
|
||||
composerInput.current?.focus();
|
||||
}
|
||||
}, [accountId, hydrated, onboardingStep, presetMessageFinished, profileComplete, profileOpen]);
|
||||
}, [accountId, activeAccountDialog, hydrated, onboardingStep, presetMessageFinished, profileComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mobileSidebarOpen) return;
|
||||
window.requestAnimationFrame(() => sidebarCloseButton.current?.focus());
|
||||
const closeOnEscape = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
if (accountMenuOpen) return;
|
||||
setMobileSidebarOpen(false);
|
||||
window.requestAnimationFrame(() => mobileMenuTrigger.current?.focus());
|
||||
return;
|
||||
@@ -751,22 +768,48 @@ export default function Home() {
|
||||
};
|
||||
window.addEventListener("keydown", closeOnEscape);
|
||||
return () => window.removeEventListener("keydown", closeOnEscape);
|
||||
}, [mobileSidebarOpen]);
|
||||
}, [accountMenuOpen, mobileSidebarOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!profileOpen) return;
|
||||
(redeemOpen ? redeemInput.current : closeButton.current)?.focus();
|
||||
if (!accountMenuOpen) return;
|
||||
const dismissMenu = (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (target instanceof Node && !accountMenu.current?.contains(target)) setAccountMenuOpen(false);
|
||||
};
|
||||
const closeOnEscape = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
setAccountMenuOpen(false);
|
||||
window.requestAnimationFrame(() => accountTrigger.current?.focus());
|
||||
};
|
||||
document.addEventListener("mousedown", dismissMenu);
|
||||
window.addEventListener("keydown", closeOnEscape);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", dismissMenu);
|
||||
window.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [accountMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeAccountDialog === null) return;
|
||||
window.requestAnimationFrame(() => {
|
||||
if (signingOut) return;
|
||||
if (activeAccountDialog === "redeem") redeemInput.current?.focus();
|
||||
else closeButton.current?.focus();
|
||||
});
|
||||
const closeOnEscape = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
closeAccount();
|
||||
if (signingOut) return;
|
||||
setActiveAccountDialog(null);
|
||||
const returnTarget = dialogReturnTarget.current;
|
||||
window.requestAnimationFrame(() => returnTarget?.focus());
|
||||
return;
|
||||
}
|
||||
const container = profileDialog.current;
|
||||
const container = accountDialog.current;
|
||||
if (container) keepFocusWithin(event, container);
|
||||
};
|
||||
window.addEventListener("keydown", closeOnEscape);
|
||||
return () => window.removeEventListener("keydown", closeOnEscape);
|
||||
}, [profileOpen, redeemOpen]);
|
||||
}, [activeAccountDialog, signingOut]);
|
||||
|
||||
async function refreshAccount() {
|
||||
try {
|
||||
@@ -888,21 +931,39 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
function openAccount(showRedeem = false) {
|
||||
setMobileSidebarOpen(false);
|
||||
if (profileComplete) setProfileDraft(profile);
|
||||
setProfileNotice("");
|
||||
setRedeemOpen(showRedeem);
|
||||
setRedeemError("");
|
||||
setRedeemMessage("");
|
||||
setProfileOpen(true);
|
||||
function toggleAccountMenu() {
|
||||
setActiveAccountDialog(null);
|
||||
setAccountError("");
|
||||
setAccountMenuOpen((current) => !current);
|
||||
}
|
||||
|
||||
function closeAccount() {
|
||||
setProfileOpen(false);
|
||||
const returnTarget = window.matchMedia("(max-width: 767px)").matches
|
||||
? mobileMenuTrigger.current
|
||||
: accountTrigger.current;
|
||||
function openAccountDialog(dialog: AccountDialog, returnTarget: HTMLButtonElement | null = accountTrigger.current) {
|
||||
dialogReturnTarget.current = returnTarget ?? accountTrigger.current;
|
||||
setAccountMenuOpen(false);
|
||||
setAccountError("");
|
||||
switch (dialog) {
|
||||
case "profile":
|
||||
setProfileDraft(profile);
|
||||
setProfileNotice("");
|
||||
break;
|
||||
case "redeem":
|
||||
setRedeemError("");
|
||||
setRedeemMessage("");
|
||||
break;
|
||||
case "logout":
|
||||
break;
|
||||
default: {
|
||||
const unreachable: never = dialog;
|
||||
return unreachable;
|
||||
}
|
||||
}
|
||||
setActiveAccountDialog(dialog);
|
||||
}
|
||||
|
||||
function closeAccountDialog() {
|
||||
if (signingOut) return;
|
||||
setActiveAccountDialog(null);
|
||||
const returnTarget = dialogReturnTarget.current;
|
||||
window.requestAnimationFrame(() => returnTarget?.focus());
|
||||
}
|
||||
|
||||
@@ -1184,15 +1245,13 @@ export default function Home() {
|
||||
if (!question || !activeSession || !modelCatalog || pendingSessionId || cancellationInFlight.current || pendingConsultation.current || !account) return;
|
||||
|
||||
if (account.credits <= 0) {
|
||||
openAccount(true);
|
||||
openAccountDialog("redeem", creditTrigger.current);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isProfileComplete(profile)) {
|
||||
setProfileDraft(profile);
|
||||
openAccountDialog("profile");
|
||||
setProfileNotice("请先补充出生资料,才能进行星盘计算。");
|
||||
setRedeemOpen(false);
|
||||
setProfileOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1324,7 +1383,7 @@ export default function Home() {
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
const errorPayload = contentType.includes("application/json") ? await response.json() : { message: await response.text() };
|
||||
if (response.status === 401) window.location.assign("/login");
|
||||
if (response.status === 402) openAccount(true);
|
||||
if (response.status === 402) openAccountDialog("redeem", creditTrigger.current);
|
||||
throw new Error(payloadMessage(errorPayload, "服务暂时不可用"));
|
||||
}
|
||||
if (!response.body) throw new Error("浏览器未收到可读取的回答流");
|
||||
@@ -1489,9 +1548,9 @@ export default function Home() {
|
||||
|
||||
return (
|
||||
<main className={`chat-app ${mobileSidebarOpen ? "sidebar-open" : ""}`}>
|
||||
<button className="sidebar-backdrop" tabIndex={-1} aria-label="关闭聊天记录" type="button" onClick={() => setMobileSidebarOpen(false)} />
|
||||
<aside className="sidebar" ref={sidebar} id="chat-sidebar" aria-label="对话导航" inert={profileOpen}>
|
||||
<div className="brand-row"><span className="brand-mark" aria-hidden="true" /><strong>Jyotisha</strong><button className="sidebar-close" ref={sidebarCloseButton} aria-label="关闭聊天记录" type="button" onClick={() => setMobileSidebarOpen(false)}><X aria-hidden="true" /></button></div>
|
||||
<button className="sidebar-backdrop" tabIndex={-1} aria-label="关闭聊天记录" type="button" onClick={() => { setAccountMenuOpen(false); setMobileSidebarOpen(false); }} />
|
||||
<aside className="sidebar" ref={sidebar} id="chat-sidebar" aria-label="对话导航" inert={activeAccountDialog !== null}>
|
||||
<div className="brand-row"><span className="brand-mark" aria-hidden="true" /><strong>Jyotisha</strong><button className="sidebar-close" ref={sidebarCloseButton} aria-label="关闭聊天记录" type="button" onClick={() => { setAccountMenuOpen(false); setMobileSidebarOpen(false); }}><X aria-hidden="true" /></button></div>
|
||||
<button className="new-chat" type="button" onClick={() => void startNewChat()} disabled={!hydrated || !account || !modelCatalog || creatingSession || Boolean(pendingSessionId) || cancellationPending}><Plus aria-hidden="true" /> {creatingSession ? "正在创建" : "新对话"}</button>
|
||||
<nav className="session-nav" aria-label="聊天记录">
|
||||
<span className="sidebar-label">聊天记录</span>
|
||||
@@ -1515,23 +1574,36 @@ export default function Home() {
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
<div className="sidebar-footer">
|
||||
<button className="profile-trigger" ref={accountTrigger} type="button" onClick={() => openAccount()}>
|
||||
<div className="sidebar-footer" ref={accountMenu}>
|
||||
<button className="profile-trigger" ref={accountTrigger} type="button" aria-expanded={accountMenuOpen} aria-controls="account-menu" aria-haspopup="menu" onClick={toggleAccountMenu}>
|
||||
<span className="profile-initial" aria-hidden="true">{profile.name.trim().slice(0, 1) || account?.user.email?.slice(0, 1).toUpperCase() || "你"}</span>
|
||||
<span><b>{profile.name.trim() || account?.user.email || "账户"}</b></span>
|
||||
<ChevronRight className="chevron" aria-hidden="true" />
|
||||
<ChevronRight className={`chevron ${accountMenuOpen ? "is-open" : ""}`} aria-hidden="true" />
|
||||
</button>
|
||||
{accountMenuOpen && (
|
||||
<div className="account-menu" id="account-menu" role="menu" aria-label="账户菜单">
|
||||
<div className="account-menu-identity">
|
||||
<span className="account-menu-avatar" aria-hidden="true">{profile.name.trim().slice(0, 1) || account.user.email?.slice(0, 1).toUpperCase() || "你"}</span>
|
||||
<span><b>{profile.name.trim() || "账户"}</b><small>{account.user.email || "尚未读取邮箱"}</small></span>
|
||||
</div>
|
||||
<button className="account-menu-item" role="menuitem" type="button" onClick={() => openAccountDialog("profile")}><UserRound aria-hidden="true" /><span>个人资料</span><ChevronRight aria-hidden="true" /></button>
|
||||
<button className="account-menu-item" role="menuitem" type="button" onClick={() => openAccountDialog("redeem")}><Gift aria-hidden="true" /><span>兑换点数</span><small>{account.credits} 点</small></button>
|
||||
{account?.isAdmin && <Link className="account-menu-item" href="/admin/codes" role="menuitem" onClick={() => setAccountMenuOpen(false)}><KeyRound aria-hidden="true" /><span>管理兑换码</span><ChevronRight aria-hidden="true" /></Link>}
|
||||
<div className="account-menu-separator" role="separator" />
|
||||
<button className="account-menu-item account-menu-danger" role="menuitem" type="button" onClick={() => openAccountDialog("logout")}><LogOut aria-hidden="true" /><span>退出登录</span></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="chat-panel" inert={profileOpen || mobileSidebarOpen}>
|
||||
<section className="chat-panel" inert={activeAccountDialog !== null || mobileSidebarOpen}>
|
||||
<header className="chat-header">
|
||||
<button className="mobile-menu" ref={mobileMenuTrigger} aria-label="打开聊天记录" aria-controls="chat-sidebar" aria-expanded={mobileSidebarOpen} type="button" onClick={() => setMobileSidebarOpen(true)}><Menu aria-hidden="true" /></button>
|
||||
<div>
|
||||
<strong>{activeSession?.title || "新对话"}</strong>
|
||||
<span><i className={`status ${isLoading ? "status-loading" : "status-idle"}`} />{isLoading ? (consultationPhase === "undo" ? "即将发送,可撤回" : activeStreamingText ? "正在回答" : "正在核对星盘信息") : "基于星盘证据回答"}</span>
|
||||
</div>
|
||||
<button className="credit-button" type="button" onClick={() => openAccount(account?.credits === 0)} aria-label={account ? `余额 ${account.credits} 点,打开账户与兑换码` : accountError || "读取余额中"}>
|
||||
<button className="credit-button" ref={creditTrigger} type="button" onClick={() => openAccountDialog("redeem", creditTrigger.current)} aria-label={account ? `余额 ${account.credits} 点,兑换点数` : accountError || "读取余额中"}>
|
||||
<Sparkles className="credit-icon" aria-hidden="true" />
|
||||
<span>{account ? account.credits : "—"}</span>
|
||||
</button>
|
||||
@@ -1693,59 +1765,53 @@ export default function Home() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className={`profile-overlay ${profileOpen ? "is-open" : ""}`} aria-hidden={!profileOpen} inert={!profileOpen} onMouseDown={closeAccount}>
|
||||
<section className="profile-dialog" ref={profileDialog} role="dialog" aria-modal="true" aria-labelledby="profile-title" onMouseDown={(event) => event.stopPropagation()}>
|
||||
<header>
|
||||
<h2 id="profile-title">账户与出生资料</h2>
|
||||
<button className="dialog-close" ref={closeButton} aria-label="关闭" type="button" onClick={closeAccount}><X aria-hidden="true" /></button>
|
||||
</header>
|
||||
{activeAccountDialog !== null && (
|
||||
<div className="account-modal-overlay" onMouseDown={closeAccountDialog}>
|
||||
<section className={`account-modal ${accountDialogClasses[activeAccountDialog]}`} ref={accountDialog} role="dialog" aria-modal="true" aria-labelledby="account-dialog-title" onMouseDown={(event) => event.stopPropagation()}>
|
||||
<header className="account-modal-header">
|
||||
<h2 id="account-dialog-title">{accountDialogTitles[activeAccountDialog]}</h2>
|
||||
<button className="dialog-close" ref={closeButton} aria-label="关闭" type="button" onClick={closeAccountDialog} disabled={signingOut}><X aria-hidden="true" /></button>
|
||||
</header>
|
||||
|
||||
<section className="account-summary" aria-label="账户信息">
|
||||
<div><span>邮箱</span><strong>{account?.user.email || "尚未读取"}</strong></div>
|
||||
<div><span>剩余点数</span><strong>{account?.credits ?? "—"}</strong></div>
|
||||
</section>
|
||||
{accountError && <p className="form-error" role="alert">{accountError}</p>}
|
||||
{activeAccountDialog === "profile" && (
|
||||
<>
|
||||
{accountError && <p className="form-error" role="alert">{accountError}</p>}
|
||||
{profileNotice && <p className="form-success" role="status">{profileNotice}</p>}
|
||||
<form className="profile-form" onSubmit={saveProfile}>
|
||||
<ProfileFields value={profileDraft} onChange={setProfileDraft} />
|
||||
<button className="button-primary save-profile" type="submit" disabled={profileSaving}>{profileSaving ? "保存中" : "保存出生资料"}</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
|
||||
<section className="sheet-section">
|
||||
<button className="section-toggle" type="button" aria-expanded={redeemOpen} onClick={() => setRedeemOpen((current) => !current)}>
|
||||
<span><b>兑换点数</b><small>输入兑换码后余额会立即更新</small></span>{redeemOpen ? <Minus aria-hidden="true" /> : <Plus aria-hidden="true" />}
|
||||
</button>
|
||||
{redeemOpen && (
|
||||
<form className="redeem-form" onSubmit={redeem}>
|
||||
<label htmlFor="redeem-code">兑换码</label>
|
||||
<div>
|
||||
<input id="redeem-code" ref={redeemInput} autoComplete="off" value={redeemCode} onChange={(event) => { setRedeemCode(event.target.value); setRedeemError(""); setRedeemMessage(""); }} placeholder="输入完整兑换码" />
|
||||
<button className="button-primary" type="submit" disabled={!redeemCode.trim() || redeeming}>{redeeming ? "兑换中" : "兑换"}</button>
|
||||
{activeAccountDialog === "redeem" && (
|
||||
<>
|
||||
<div className="redeem-balance"><span>当前余额</span><strong>{account.credits} 点</strong></div>
|
||||
<form className="redeem-form account-redeem-form" onSubmit={redeem}>
|
||||
<label htmlFor="redeem-code">兑换码</label>
|
||||
<div>
|
||||
<input id="redeem-code" ref={redeemInput} autoComplete="off" value={redeemCode} onChange={(event) => { setRedeemCode(event.target.value); setRedeemError(""); setRedeemMessage(""); }} placeholder="输入完整兑换码" />
|
||||
<button className="button-primary" type="submit" disabled={!redeemCode.trim() || redeeming}>{redeeming ? "兑换中" : "兑换"}</button>
|
||||
</div>
|
||||
{redeemError && <p className="form-error" role="alert">{redeemError}</p>}
|
||||
{redeemMessage && <p className="form-success" role="status">{redeemMessage}</p>}
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeAccountDialog === "logout" && (
|
||||
<>
|
||||
<p className="logout-copy">退出后,需要重新登录才能继续查看对话。</p>
|
||||
{accountError && <p className="form-error" role="alert">{accountError}</p>}
|
||||
<div className="dialog-actions">
|
||||
<button className="button-secondary" type="button" onClick={closeAccountDialog} disabled={signingOut}>取消</button>
|
||||
<button className="button-primary danger-primary" type="button" onClick={() => void signOut()} disabled={signingOut}>{signingOut ? "正在退出" : "确认退出"}</button>
|
||||
</div>
|
||||
{redeemError && <p className="form-error" role="alert">{redeemError}</p>}
|
||||
{redeemMessage && <p className="form-success" role="status">{redeemMessage}</p>}
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="sheet-section birth-section">
|
||||
<div className="section-heading"><b>出生资料</b><small>加密传输并保存到云端,用于此账号的所有对话</small></div>
|
||||
<div className="default-chart-card" aria-label="当前默认星盘">
|
||||
<div>
|
||||
<span>当前默认星盘</span>
|
||||
<strong>{profileDraft.name.trim() || "未命名"}</strong>
|
||||
<small>角色:本人</small>
|
||||
</div>
|
||||
<button className="button-secondary" type="button" onClick={() => profileDialog.current?.querySelector<HTMLInputElement>("#profile-name")?.focus()}>管理星盘库</button>
|
||||
</div>
|
||||
{profileNotice && <p className="form-success" role="status">{profileNotice}</p>}
|
||||
<form className="profile-form" onSubmit={saveProfile}>
|
||||
<ProfileFields value={profileDraft} onChange={setProfileDraft} />
|
||||
<button className="button-primary save-profile" type="submit" disabled={profileSaving || !account}>{profileSaving ? "保存中" : "保存出生资料"}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<footer className="account-actions">
|
||||
{account?.isAdmin && <Link className="button-secondary" href="/admin/codes">管理兑换码</Link>}
|
||||
<button className="button-secondary danger-button" type="button" onClick={() => void signOut()} disabled={signingOut}>{signingOut ? "正在退出" : "退出登录"}</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user