feat: replace account sheet with task dialogs

This commit is contained in:
Jesse_Chen
2026-07-17 16:30:18 +08:00
parent 7fdf3e39d3
commit 0f301049aa
5 changed files with 663 additions and 122 deletions
@@ -0,0 +1,416 @@
# Account Menu and Task Dialogs Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the right-side account sheet with an anchored account popover plus focused profile, redeem, and logout-confirmation dialogs.
**Architecture:** Keep account/profile data and API mutations in `frontend/src/app/page.tsx`, but split presentation state into `accountMenuOpen` and `activeAccountDialog`. The sidebar trigger owns the non-modal menu; the credit button and system fallbacks route directly to the redeem dialog; each modal renders only its own task and shares one focus-trapped overlay contract.
**Tech Stack:** Next.js 16 App Router, React 19, TypeScript 5, vanilla CSS design tokens, Node test runner, existing Lucide icons.
## Global Constraints
- Preserve the warm editorial design tokens in `frontend/DESIGN.md`; add no raw colors, arbitrary shadow recipes, or new dependencies.
- The sidebar account trigger opens a popover; the upper-right credit trigger opens the redeem dialog directly.
- The administrator-only `管理兑换码` action navigates to `/admin/codes` in the same tab.
- `退出登录` must open a confirmation dialog; only `确认退出` signs out.
- Missing-profile and insufficient-credit flows open their task dialogs directly.
- Popover rows and dialog controls retain a minimum 44px target.
- Support outside click where appropriate, Escape, focus trapping, focus return, keyboard navigation, and reduced motion.
- Remove the former right-side account sheet markup, CSS, and overloaded state.
- Do not commit or push unrelated working-tree changes.
---
### Task 1: Lock the interaction contract and design-system primitives
**Files:**
- Modify: `frontend/tests/starter-questions.test.ts`
- Modify: `frontend/DESIGN.md`
**Interfaces:**
- Consumes: existing source-contract test helpers `pageSource`, `globalStyles`, and `sourceBetween`.
- Produces: failing assertions for the account popover, direct redeem route, three dialogs, and removal of the sheet; documented `Account popover`, `Profile dialog`, `Redeem dialog`, and `Logout dialog` primitives.
- [ ] **Step 1: Add failing structural tests**
Append tests that inspect behavior-bearing names and relationships rather than prose copy:
```ts
test("routes account actions through a popover and focused dialogs", () => {
assert.match(pageSource, /const \[accountMenuOpen, setAccountMenuOpen\] = useState\(false\)/);
assert.match(pageSource, /const \[activeAccountDialog, setActiveAccountDialog\] = useState<AccountDialog>\(null\)/);
assert.match(pageSource, /className="account-menu"/);
assert.match(pageSource, /onClick=\{\(\) => openAccountDialog\("profile"\)\}/);
assert.match(pageSource, /onClick=\{\(\) => openAccountDialog\("redeem"/);
assert.match(pageSource, /onClick=\{\(\) => openAccountDialog\("logout"\)\}/);
});
test("removes the monolithic account sheet", () => {
assert.doesNotMatch(pageSource, /profile-overlay|profile-dialog|openAccount\(/);
assert.doesNotMatch(globalStyles, /\.profile-overlay|\.profile-dialog/);
});
test("keeps admin navigation separate from task dialogs", () => {
const menu = sourceBetween(pageSource, 'className="account-menu"', '</div>\n </div>');
assert.match(menu, /href="\/admin\/codes"/);
assert.match(menu, /account\?\.isAdmin/);
});
```
- [ ] **Step 2: Run the targeted tests and confirm the expected red state**
Run: `npm test -- --test-name-pattern="account|sheet|admin navigation"`
Expected: the new tests fail because `accountMenuOpen`, `activeAccountDialog`, `.account-menu`, and focused-dialog routes do not exist yet.
- [ ] **Step 3: Update the design contract before styling**
Replace `### Account sheet` in `frontend/DESIGN.md` with four documented primitives:
```md
### Account popover
- **Structure:** identity header, profile action, redeem action with balance, administrator-only code-management link, divider, and logout action.
- **Surface:** 280px elevated canvas popover anchored above the sidebar account trigger; warm hairline, existing elevated shadow, no nested cards.
- **States:** closed, open, hover, focus-visible, administrator, signing-out transition.
- **Accessibility:** `aria-expanded`, `aria-controls`, menu semantics, 44px rows, outside-click and Escape dismissal, focus return.
### Profile dialog
- **Structure:** profile title, existing profile fields, inline result, save action.
- **Width:** 560px desktop maximum with viewport-safe spacing and internal scrolling.
- **States:** open, invalid, saving, success, error.
### Redeem dialog
- **Structure:** current balance, redemption-code form, inline result.
- **Width:** 420px desktop maximum.
- **States:** open, submitting, success, error.
### Logout dialog
- **Structure:** confirmation title and explanation, cancel action, destructive confirm action.
- **States:** open, signing out, error.
```
- [ ] **Step 4: Verify the documentation diff**
Run: `git diff --check -- frontend/DESIGN.md frontend/tests/starter-questions.test.ts`
Expected: exit 0 with no whitespace errors.
---
### Task 2: Split surface state and route every account entry correctly
**Files:**
- Modify: `frontend/src/app/page.tsx`
**Interfaces:**
- Consumes: existing `Profile`, `Account`, `redeem`, `saveProfile`, `signOut`, `keepFocusWithin`, `accountTrigger`, `redeemInput`, and `closeButton` behavior.
- Produces: `type AccountDialog = "profile" | "redeem" | "logout" | null`, `accountMenuOpen`, `activeAccountDialog`, `toggleAccountMenu`, `openAccountDialog`, and `closeAccountDialog`.
- [ ] **Step 1: Replace the overloaded state and refs**
Add the dialog type beside other UI-state types:
```ts
type AccountDialog = "profile" | "redeem" | "logout" | null;
```
Replace `profileOpen` and `redeemOpen` with:
```ts
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
const [activeAccountDialog, setActiveAccountDialog] = useState<AccountDialog>(null);
```
Replace the sheet ref with explicit surface refs:
```ts
const accountMenu = useRef<HTMLDivElement>(null);
const accountDialog = useRef<HTMLElement>(null);
const creditTrigger = useRef<HTMLButtonElement>(null);
const dialogReturnTarget = useRef<HTMLButtonElement | null>(null);
```
- [ ] **Step 2: Add explicit open and close handlers**
Replace `openAccount` and `closeAccount` with handlers shaped as follows:
```ts
function toggleAccountMenu() {
setActiveAccountDialog(null);
setAccountMenuOpen((current) => !current);
}
function openAccountDialog(dialog: Exclude<AccountDialog, null>, returnTarget = accountTrigger.current) {
dialogReturnTarget.current = returnTarget;
setAccountMenuOpen(false);
setAccountError("");
if (dialog === "profile") {
if (profileComplete) setProfileDraft(profile);
setProfileNotice("");
}
if (dialog === "redeem") {
setRedeemError("");
setRedeemMessage("");
}
setActiveAccountDialog(dialog);
}
function closeAccountDialog() {
if (signingOut) return;
setActiveAccountDialog(null);
const returnTarget = dialogReturnTarget.current;
window.requestAnimationFrame(() => returnTarget?.focus());
}
```
- [ ] **Step 3: Replace the sheet focus effect with menu dismissal and dialog focus trapping**
Use one document listener for the non-modal popover:
```ts
useEffect(() => {
if (!accountMenuOpen) return;
const dismissMenu = (event: MouseEvent) => {
if (!accountMenu.current?.contains(event.target as Node)) 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]);
```
Use `activeAccountDialog` for modal initial focus, Escape, and `keepFocusWithin`. Focus `redeemInput` for redeem and `closeButton` for profile/logout.
- [ ] **Step 4: Route system fallbacks directly**
Replace every `openAccount(true)` call with:
```ts
openAccountDialog("redeem", creditTrigger.current);
```
Replace the incomplete-profile branch with:
```ts
setProfileDraft(profile);
setProfileNotice("请先补充出生资料,才能进行星盘计算。");
openAccountDialog("profile");
```
Use `activeAccountDialog === null` in the onboarding focus effect and `activeAccountDialog !== null` for modal background inertness.
- [ ] **Step 5: Run targeted tests**
Run: `npm test -- --test-name-pattern="account|sheet|admin navigation"`
Expected: the state/routing assertions pass; markup or CSS assertions may remain red until Tasks 3 and 4.
---
### Task 3: Replace the sheet markup with the account popover and three dialogs
**Files:**
- Modify: `frontend/src/app/page.tsx`
**Interfaces:**
- Consumes: Task 2 state, handlers, refs, existing profile and redeem forms, and `/admin/codes` route.
- Produces: `.account-menu`, `.account-modal-overlay`, `.account-modal`, `.profile-modal`, `.redeem-modal`, and `.logout-modal` markup.
- [ ] **Step 1: Add the anchored popover beside the sidebar account trigger**
Wrap the trigger and menu in the existing `.sidebar-footer`. Add `aria-expanded`, `aria-controls`, and a rotating chevron state. Render the menu only while open:
```tsx
<button
className="profile-trigger"
ref={accountTrigger}
type="button"
aria-expanded={accountMenuOpen}
aria-controls="account-menu"
onClick={toggleAccountMenu}
>
...
</button>
{accountMenuOpen && (
<div className="account-menu" id="account-menu" ref={accountMenu} role="menu">
<div className="account-menu-identity">...</div>
<button role="menuitem" type="button" onClick={() => openAccountDialog("profile")}>...</button>
<button role="menuitem" type="button" onClick={() => openAccountDialog("redeem")}>...</button>
{account?.isAdmin && <Link role="menuitem" href="/admin/codes">...</Link>}
<div className="account-menu-separator" />
<button className="account-menu-danger" role="menuitem" type="button" onClick={() => openAccountDialog("logout")}>...</button>
</div>
)}
```
- [ ] **Step 2: Route the credit button directly to redeem**
Add `ref={creditTrigger}` and change its handler and accessible name:
```tsx
onClick={() => openAccountDialog("redeem", creditTrigger.current)}
aria-label={account ? `余额 ${account.credits} 点,兑换点数` : accountError || "读取余额中"}
```
- [ ] **Step 3: Render a profile-only modal**
When `activeAccountDialog === "profile"`, render the shared scrim, dialog header, existing `ProfileFields`, inline notice/error, and save action. Do not render account email, credits, redemption form, admin link, or logout control inside this modal.
- [ ] **Step 4: Render a redeem-only modal**
When `activeAccountDialog === "redeem"`, render the current balance, existing redemption-code form, inline error/success, and close control. Do not render profile fields.
- [ ] **Step 5: Render the logout confirmation modal**
When `activeAccountDialog === "logout"`, render the confirmation copy, cancel action, inline `accountError`, and destructive confirmation:
```tsx
<button className="button-primary danger-primary" type="button" onClick={() => void signOut()} disabled={signingOut}>
{signingOut ? "正在退出" : "确认退出"}
</button>
```
- [ ] **Step 6: Remove the old account sheet markup completely**
Delete `.profile-overlay`, `.profile-dialog`, `.account-summary`, `.sheet-section`, `.section-toggle`, and `.account-actions` JSX usage. Remove obsolete `Minus` import if no longer used.
- [ ] **Step 7: Run the complete frontend tests**
Run: `npm test`
Expected: all existing and new tests pass.
---
### Task 4: Style the popover and modal family with existing tokens
**Files:**
- Modify: `frontend/src/app/globals.css`
**Interfaces:**
- Consumes: Task 3 class names and tokens documented in `frontend/DESIGN.md`.
- Produces: collision-safe account menu, centered modal shell, responsive form layout, focus/hover/active states, and reduced-motion behavior.
- [ ] **Step 1: Add account popover styles**
Implement the menu with existing tokens:
```css
.sidebar-footer { position: relative; }
.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 button, .account-menu a { 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); text-align: left; text-decoration: none; }
```
Add identity, separator, trailing balance, danger, hover, active, and focus-visible rules using only existing tokens.
- [ ] **Step 2: Replace sheet CSS with centered modal CSS**
Use one overlay and width variants:
```css
.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); }
```
Keep the existing profile-grid, location-grid, input, button, form-result, and focus styles. Add `.dialog-actions`, `.redeem-balance`, and `.danger-primary` with documented tokens.
- [ ] **Step 3: Add responsive and reduced-motion behavior**
At mobile widths, retain 16px viewport spacing, keep the popover within the sidebar/viewport, collapse profile grids at 480px, and cap dialog height. Under `prefers-reduced-motion: reduce`, disable menu and dialog transform animations.
- [ ] **Step 4: Confirm the source contract and CSS cleanup**
Run: `npm test`
Expected: all tests pass, including removal of `.profile-overlay` and `.profile-dialog`.
Run: `rg -n "profile-overlay|profile-dialog|openAccount\(|redeemOpen|section-toggle|account-summary" frontend/src frontend/tests`
Expected: no obsolete account-sheet hits.
---
### Task 5: Verify behavior through the shipped surface and commit atomically
**Files:**
- Verify: `frontend/src/app/page.tsx`
- Verify: `frontend/src/app/globals.css`
- Verify: `frontend/DESIGN.md`
- Verify: `frontend/tests/starter-questions.test.ts`
**Interfaces:**
- Consumes: completed Tasks 14.
- Produces: verified build, browser evidence across breakpoints, and one feature commit without unrelated files.
- [ ] **Step 1: Run static and production gates**
Run from `frontend/`:
```bash
npm test
npx tsc --noEmit
npm run lint
npm run build
```
Expected: every command exits 0.
- [ ] **Step 2: Run repository hygiene checks**
Run:
```bash
git diff --check
git diff -- frontend/DESIGN.md frontend/src/app/page.tsx frontend/src/app/globals.css frontend/tests/starter-questions.test.ts
```
Expected: no whitespace errors and only the confirmed account-surface changes plus previously approved hint/card removals in the already-dirty frontend files.
- [ ] **Step 3: Manual browser QA at 1280px, 768px, and 375px**
At each breakpoint verify:
1. Sidebar account trigger opens the anchored popover and leaves the chat readable.
2. Identity, profile, redeem, admin visibility, and logout rows are correctly ordered.
3. Credit trigger opens redeem directly.
4. Profile dialog contains no account/redeem/logout content.
5. Redeem success updates all balances; failure preserves the code.
6. Logout opens confirmation; cancel returns focus; confirm exposes pending state.
7. Escape, outside click, focus return, focus trap, and keyboard navigation work.
8. No right-side account sheet appears and no console error/warning is emitted.
- [ ] **Step 4: Stage only feature files and inspect the staged diff**
```bash
git add frontend/DESIGN.md frontend/src/app/page.tsx frontend/src/app/globals.css frontend/tests/starter-questions.test.ts docs/superpowers/plans/2026-07-17-account-menu-dialogs.md
git diff --staged --check
git diff --staged --stat
git diff --staged
```
Expected: unrelated research manifests, image assets, and `frontend/plans/` remain unstaged.
- [ ] **Step 5: Commit without pushing**
```bash
git commit -m "feat: add focused account dialogs"
git log -1 --oneline
git status --short
```
Expected: the feature commit is created locally; no push occurs; unrelated user changes remain in the working tree.
+23 -4
View File
@@ -130,11 +130,30 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3:
- **Timing:** use the browser's local hour: morning 05:0010:59, noon 11:0013:59, afternoon 14:0017:59, evening 18:0022:59, and late night 23:0004:59.
- **Variation:** each time band has three concise prompts; select one once per visit so re-renders do not change the sentence.
### Account sheet
### Account popover
- **Structure:** title, account summary, redeem section, profile form, actions.
- **Surface:** canvas sheet over warm scrim; the account summary and sections are separated by hairlines rather than dark cards.
- **States:** closed, opening, open, validation error, success, saving.
- **Structure:** identity header, profile action, redeem action with balance, administrator-only code-management link, divider, and logout action.
- **Surface:** 280px elevated canvas popover anchored above the sidebar account trigger; warm hairline, existing elevated shadow, no nested cards.
- **States:** closed, open, hover, focus-visible, administrator, and logout routing.
- **Accessibility:** `aria-expanded`, `aria-controls`, menu semantics, 44px rows, outside-click and Escape dismissal, and focus return.
### Profile dialog
- **Structure:** profile title, existing profile fields, inline result, and save action.
- **Width:** 560px desktop maximum with viewport-safe spacing and internal scrolling.
- **States:** open, invalid, saving, success, and error.
### Redeem dialog
- **Structure:** current balance, redemption-code form, and inline result.
- **Width:** 420px desktop maximum.
- **States:** open, submitting, success, and error.
### Logout dialog
- **Structure:** confirmation title and explanation, cancel action, and destructive confirm action.
- **Width:** 400px desktop maximum.
- **States:** open, signing out, and error.
### Admin panel and data table
+45 -32
View File
@@ -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
View File
@@ -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>
);
}
+27
View File
@@ -72,3 +72,30 @@ test("centers the credit value with its icon", () => {
assert.match(creditValueStyles, /align-items:\s*center/);
assert.match(creditValueStyles, /line-height:\s*1/);
});
test("routes account actions through a popover and focused dialogs", () => {
// Given: the account surface state and entry-point handlers.
// When: the page source is inspected for independent menu and dialog routes.
// Then: each account task has a focused destination instead of one combined sheet.
assert.match(pageSource, /const \[accountMenuOpen, setAccountMenuOpen\] = useState\(false\)/);
assert.match(pageSource, /const \[activeAccountDialog, setActiveAccountDialog\] = useState<AccountDialog \| null>\(null\)/);
assert.match(pageSource, /className="account-menu"/);
assert.match(pageSource, /openAccountDialog\("profile"/);
assert.match(pageSource, /openAccountDialog\("redeem"/);
assert.match(pageSource, /openAccountDialog\("logout"/);
});
test("removes the monolithic account sheet", () => {
// Given: the former sheet implementation names.
// When: the page and global styles are inspected.
// Then: no right-side account sheet remains.
assert.doesNotMatch(pageSource, /profile-overlay|profile-dialog|openAccount\(/);
assert.doesNotMatch(globalStyles, /\.profile-overlay|\.profile-dialog/);
});
test("keeps admin navigation separate from account task dialogs", () => {
// Given: the administrator-only route and the new account menu.
// When: their source relationship is inspected.
// Then: code management stays a guarded navigation action rather than a modal.
assert.match(pageSource, /account\?\.isAdmin\s*&&\s*<Link[^>]+href="\/admin\/codes"[^>]+role="menuitem"/);
});