feat: replace account sheet with task dialogs
This commit is contained in:
@@ -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 1–4.
|
||||
- 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
@@ -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:00–10:59, noon 11:00–13:59, afternoon 14:00–17:59, evening 18:00–22:59, and late night 23:00–04: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
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user