perf: smooth core navigation and defer heavy panels
This commit is contained in:
@@ -76,6 +76,8 @@ Mobile adaptation is handled primarily through page-level structure classes plus
|
||||
|
||||
Client stores in `src/lib/*-store.ts` mediate API calls and local UI state. When fixing a UI persistence bug, inspect both the component and the matching store/API route.
|
||||
|
||||
Navigation performance is handled as part of the frontend architecture, not only by backend route timing. `src/components/navbar.tsx` prefetches the core public routes after browser idle time and defers its initial logged-in profile refresh so a fresh page load does not compete with the user's first navigation. `src/components/visit-tracker.tsx` posts site statistics with `keepalive` after idle time because analytics should not block first paint. `src/app/create/page.tsx` keeps each creation workflow panel behind `next/dynamic`, so loading the create center does not eagerly initialize every image, video, and reverse-prompt workflow. `src/app/profile/page.tsx` keeps the parent account shell light and lets creation history, credit records, and orders mount their stores only inside their respective tab components. Keep `scripts/test-navigation-performance-policy.mjs` aligned with these constraints when changing the app shell, create center, or profile page.
|
||||
|
||||
## API Architecture
|
||||
|
||||
All APIs are route handlers in `src/app/api`.
|
||||
|
||||
@@ -32,7 +32,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
|
||||
| Footer content missing or not Markdown-rendered | `src/components/site-footer.tsx`, `src/components/site-policy-page.tsx`, `src/lib/site-config.ts`, `src/app/api/site-config/route.ts` | Config response fields, Markdown renderer, fallback defaults, PUT persistence. |
|
||||
| Policy pages start mid-page after navigation | `src/components/site-policy-page.tsx`, `src/app/about/page.tsx`, `src/app/terms/page.tsx`, `src/app/privacy/page.tsx`, `src/app/help/page.tsx` | Scroll reset behavior and shared policy page wrapper. |
|
||||
| Site name/logo/favicon not updating | `src/components/site-config-sync.tsx`, `src/components/site-brand.tsx`, `src/app/api/site-config/route.ts`, `src/lib/local-storage.ts` | `site_config` row, base64 image save, generated `/api/local-storage/*` URL. |
|
||||
| Clicking navbar between home/create/gallery/profile feels slow while server-side route TTFB is normal | `src/lib/site-config.ts`, `src/app/api/site-config/route.ts`, `src/components/navbar.tsx`, `src/components/site-brand.tsx`, `src/components/site-footer.tsx`, `src/app/page.tsx` | Compare production local route timings with real browser navigation. If pages and `/api/site-config` are fast on the server but browser clicks still feel delayed, check whether multiple `useSiteConfig()` consumers are issuing duplicate concurrent config requests, and confirm the shared snapshot, 5-minute fresh-cache network skip, and in-flight request dedupe in `src/lib/site-config.ts` are present. Also confirm `/api/site-config` is not running schema/default compatibility DDL on every GET; it should cache that check once per server process and retry only after failure. Home is naturally heavier than other public routes because its prerendered HTML/RSC is larger. |
|
||||
| Clicking navbar between home/create/gallery/profile feels slow while server-side route TTFB is normal | `src/lib/site-config.ts`, `src/app/api/site-config/route.ts`, `src/components/navbar.tsx`, `src/components/site-brand.tsx`, `src/components/site-footer.tsx`, `src/app/page.tsx`, `src/app/create/page.tsx`, `src/app/profile/page.tsx`, `src/components/visit-tracker.tsx` | Compare production local route timings with real browser navigation. If pages and `/api/site-config` are fast on the server but browser clicks still feel delayed, check whether multiple `useSiteConfig()` consumers are issuing duplicate concurrent config requests, and confirm the shared snapshot, 5-minute fresh-cache network skip, and in-flight request dedupe in `src/lib/site-config.ts` are present. Also confirm `/api/site-config` is not running schema/default compatibility DDL on every GET; it should cache that check once per server process and retry only after failure. Then verify `Navbar` idle-prefetches core routes, `VisitTracker` defers `/api/site-stats`, `/create` uses `next/dynamic` for the five heavy creation panels, and `/profile` does not mount creation-history, credit-record, or order stores from the parent page. Home is naturally heavier than other public routes because its prerendered HTML/RSC is larger. Run `node --no-warnings ./scripts/test-navigation-performance-policy.mjs` after changing this area. |
|
||||
| Console reports CSP blocking `https://fonts.googleapis.cn/...` | `src/app/globals.css`, `src/proxy.ts` | `globals.css` imports Noto Serif SC from `fonts.googleapis.cn`; CSP must allow that stylesheet domain in `style-src` and the matching font CDN in `font-src`. |
|
||||
| Page content leaves large unused horizontal margins, or wide screens look like the UI was simply enlarged | `src/components/app-shell.tsx`, `src/components/navbar.tsx`, `src/components/site-footer.tsx`, page-level wrappers under `src/app/*/page.tsx`, `src/components/site-policy-page.tsx` | The viewport/background can be `w-full`, but product content should keep the original component scale and readable containers such as `max-w-7xl`, `max-w-4xl`, or `max-w-3xl`. Do not fix this by removing all max widths or scaling controls up on wide monitors. |
|
||||
| Scrollbars look native, stay visible when idle, or do not match glass UI in dialogs/pages | `src/app/globals.css`, `src/components/app-shell.tsx` | Global scrollbar styling is hidden by default and becomes visible only while wheel/touch scrolling through the `scrollbars-visible` class on `<html>`. `globals.css` owns both the hidden state and the rounded glass visible state for light/dark themes; `app-shell` owns the short-lived wheel/touch listener. Avoid adding one-off scrollbar styles to individual components unless there is a real exception. |
|
||||
|
||||
@@ -10,11 +10,11 @@ Use this document to jump directly to code before broad searching.
|
||||
| --- | --- | --- |
|
||||
| Root layout and providers | `src/app/layout.tsx`, `src/components/app-shell.tsx`, `src/app/globals.css` | App shell wires navbar, site config sync, visit tracking, theme/account sync, toaster, full-width page mounting, and transient scrollbar visibility. Keep product content at the original component scale; use centered responsive containers instead of stretching all content to viewport edges. Global scrollbars are hidden by default and briefly show the rounded glass style when wheel/touch scrolling adds `scrollbars-visible` on `<html>`. |
|
||||
| Home page | `src/app/page.tsx` | Landing/dashboard-like public entry. Check site config dependencies when changing brand text. |
|
||||
| Navbar | `src/components/navbar.tsx`, `src/components/site-brand.tsx` | Navigation, brand display, auth-aware links. User-facing nav should not include removed feature routes. Logged-in desktop/mobile user buttons should show `AuthUser.avatarUrl` first and fall back to the display nickname initial only when the avatar is missing or fails to load. |
|
||||
| Navbar | `src/components/navbar.tsx`, `src/components/site-brand.tsx` | Navigation, brand display, auth-aware links. User-facing nav should not include removed feature routes. Logged-in desktop/mobile user buttons should show `AuthUser.avatarUrl` first and fall back to the display nickname initial only when the avatar is missing or fails to load. Core routes (`/`, `/create`, `/gallery`, `/profile`) are prefetched after browser idle time, and the initial logged-in profile refresh is also idle and one-shot so it does not compete with the first route transition. |
|
||||
| Footer | `src/components/site-footer.tsx` | Uses site config for policy/help/about links and filing text; footer background spans browser width while inner content keeps the original `max-w-7xl` scale. |
|
||||
| Announcement popup | `src/components/announcement-popup.tsx`, `src/app/api/announcements/route.ts`, `src/app/globals.css` | Frontend popup behavior plus backend announcement CRUD. Desktop dialog is intentionally wide (`max-w-5xl`) for long Markdown notices; scrollbar styling is inherited from the global glass scrollbar rules. |
|
||||
| Site config sync | `src/components/site-config-sync.tsx`, `src/lib/site-config.ts`, `src/app/api/site-config/route.ts` | Site name, tab title, logo, favicon, policy Markdown, filing, membership switch. `useSiteConfig()` keeps a shared browser snapshot, skips network refresh while that snapshot is still within the 5-minute TTL, and reuses the in-flight `/api/site-config` refresh so global consumers such as navbar, footer, site-brand, and policy pages do not each create their own concurrent config request during navigation. The API route runs legacy schema/default compatibility checks once per server process and retries on failure. |
|
||||
| Visit tracking | `src/components/visit-tracker.tsx`, `src/app/api/site-stats/route.ts` | Public visit counter. |
|
||||
| Visit tracking | `src/components/visit-tracker.tsx`, `src/app/api/site-stats/route.ts` | Public visit counter. The client posts with `keepalive` after browser idle time so statistics collection does not block first paint or navbar route transitions. |
|
||||
| Security headers and iframe embedding | `src/proxy.ts`, `.env.example` | CSP is set in the Next proxy. `frame-ancestors` controls which external platforms may embed MiaoJing in an iframe; `MIAOJING_FRAME_ANCESTORS` can override the default self + mozheAPI allowlist. When external ancestors are allowed, do not send `X-Frame-Options: SAMEORIGIN`, because it blocks third-party iframes. |
|
||||
|
||||
## Public Pages
|
||||
@@ -49,7 +49,7 @@ Use this document to jump directly to code before broad searching.
|
||||
|
||||
| Feature | Primary Files | Server/API Files |
|
||||
| --- | --- | --- |
|
||||
| Tab container | `src/app/create/page.tsx` | Owns the five creation tabs. Active tab is persisted in localStorage and mirrored to `/create?type=...`, so refreshes and shared links stay on text-to-image, image-to-image, text-to-video, image-to-video, or reverse-prompt. On phones the mode switch is the single fixed icon row below the navbar; the page title and duplicate text mode strip are hidden. Mobile layout classes in this page and `src/app/globals.css` turn the create center into a chat-style flow: text-to-image sorts history from oldest to newest and auto-scrolls to the latest work above the fixed composer, hides the empty result placeholder until the user submits a prompt, renders generating tasks as the newest prompt-plus-progress message, and uses `src/components/create/mobile-creation-composer.tsx` as the fixed bottom composer with compact labeled ratio/resolution/count controls, optional style strip that expands the composer upward, prompt input, and right send button. |
|
||||
| Tab container | `src/app/create/page.tsx` | Owns the five creation tabs. Active tab is persisted in localStorage and mirrored to `/create?type=...`, so refreshes and shared links stay on text-to-image, image-to-image, text-to-video, image-to-video, or reverse-prompt. The five heavy creation panels are loaded through `next/dynamic` with a shared lightweight fallback; keep this split so opening `/create` does not eagerly bundle image, video, and reverse-prompt workflows at once. On phones the mode switch is the single fixed icon row below the navbar; the page title and duplicate text mode strip are hidden. Mobile layout classes in this page and `src/app/globals.css` turn the create center into a chat-style flow: text-to-image sorts history from oldest to newest and auto-scrolls to the latest work above the fixed composer, hides the empty result placeholder until the user submits a prompt, renders generating tasks as the newest prompt-plus-progress message, and uses `src/components/create/mobile-creation-composer.tsx` as the fixed bottom composer with compact labeled ratio/resolution/count controls, optional style strip that expands the composer upward, prompt input, and right send button. |
|
||||
| Text to image | `src/components/create/text-to-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/components/create/use-generation-job-recovery.ts`. The create button remains available while other active tasks run; duplicate in-flight submissions are still blocked by `activeSubmissionSignaturesRef`. Active jobs render through `src/components/create/generation-task-list.tsx` inside the results column and expose a cancel action that calls `PATCH /api/generation-jobs/[id]`. Model select items use `src/components/create/grouped-model-select-items.tsx` so admin global system models appear under `默认模型` and user-added keys appear under `自定义模型`. Selected model capabilities from `src/lib/model-capabilities.ts` can hide unsupported aspect ratio/resolution/format/quality controls as well as filter their options, which is required for built-in 元界 image templates such as GPT Image 2 where the docs expose `size` pixel values instead of a separate aspect-ratio control. It consumes reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery text-to-image works can fill prompt, negative prompt, model, ratio, resolution, format, quality, count, style, and guidance into the form. The mobile conversation history should only mount on mobile viewports; CSS-hidden mobile history still runs image effects if mounted on desktop. |
|
||||
| Image to image | `src/components/create/image-to-image.tsx`, `src/components/create/reference-image-mention-controls.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/components/create/use-generation-job-recovery.ts`, `src/lib/reference-image-prompt.ts`. Reference thumbnails single-click into a bare image overlay, active jobs render through `src/components/create/generation-task-list.tsx`, and the create button remains available while active tasks exist; identical in-flight submissions are still deduped. Model select items use `src/components/create/grouped-model-select-items.tsx` for `默认模型` versus `自定义模型` grouping. Selected model capabilities from `src/lib/model-capabilities.ts` can hide unsupported aspect ratio/resolution/format/quality controls as well as filter their options, which is required for built-in 元界 image templates such as GPT Image 2 where the docs expose `size` pixel values instead of a separate aspect-ratio control. 图生图 removes `自动` from ratio/resolution/count controls, defaults count to `1`, and derives ratio from Yuanjie size labels or dimensions when the selected model hides the separate ratio control. It consumes reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery image-to-image works can place reference images and fill prompt, negative prompt, model, ratio, resolution, format, quality, count, style, and strength into the form. 多参考图会显示 `@参考图1` 等标签,提示词输入框输入 `@` 可选择参考图,提交时发送 `referenceImageAnnotations`,后端把 token 与上传顺序、文件名、尺寸写入上游 prompt;分享到画廊会携带所有参考图和标注。 |
|
||||
| Text to video | `src/components/create/text-to-video.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/video/route.ts`, `src/components/create/use-generation-job-recovery.ts`. The create button remains available while active tasks exist, active jobs render through `src/components/create/generation-task-list.tsx`, running tasks can be cancelled, and model select items use `src/components/create/grouped-model-select-items.tsx` for `默认模型` versus `自定义模型` grouping. It consumes video reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery text-to-video works can fill prompt, negative prompt, model, ratio, duration, camera movement, and style. |
|
||||
@@ -97,11 +97,11 @@ Use this document to jump directly to code before broad searching.
|
||||
|
||||
| Feature | Files |
|
||||
| --- | --- |
|
||||
| Profile page | `src/app/profile/page.tsx` |
|
||||
| Profile page | `src/app/profile/page.tsx` | The profile shell should stay light on first mount: it reads only a local creation-history count for the top summary, while creation history, credit records, and orders mount their heavier stores inside the corresponding tab components. |
|
||||
| Profile API | `src/app/api/profile/route.ts`, `src/app/api/profile/theme/route.ts`, `src/lib/user-profile-defaults.ts`, `src/lib/profile-preferences.ts` | `profiles.watermark_disabled` stores no-watermark download authorization. Free users cannot enable it from their own profile, but admins can grant or revoke it per user from user management; platform display remains watermarked even when the flag is on. |
|
||||
| Creation history tab | `src/components/profile/creation-history-tab.tsx`, `src/lib/creation-history-store.ts`, `src/app/api/creation-history/route.ts` | User-private completed works. History storage and the API de-duplicate repeated rows by result URL so a single generated video does not appear twice after the local optimistic record is replaced by the server row. Video records without a thumbnail receive a local SVG thumbnail under `thumbnails/works/videos` for fast list/detail preview. |
|
||||
| Credits tab/store | `src/components/profile/credits-tab.tsx`, `src/lib/credit-records-store.ts`, `src/app/api/credit-transactions/route.ts`, `src/app/api/redeem-codes/redeem/route.ts`, `src/app/api/invitations/me/route.ts`, `src/lib/invitation-service.ts` | The credits tab includes redeem-code input, a `获取兑换码` button, and a per-user invite link. The get-code and recharge buttons open `site_config.redeem_code_mall_url` from `/api/site-config` when configured. Successful redemption calls the server transaction route, updates either `profiles.credits_balance` for credit codes or `profiles.membership_tier`/`membership_expires_at` for membership codes, refreshes the auth profile, and then reloads server credit records. Invite links use `profiles.invite_code`; registrations through `/auth/register?invite=...` create an `invitation_referrals` row and award 50 credits to both inviter and invitee. |
|
||||
| Orders tab/store | `src/components/profile/orders-tab.tsx`, `src/lib/order-store.ts`, `src/app/api/admin/orders/route.ts` |
|
||||
| Credits tab/store | `src/components/profile/credits-tab.tsx`, `src/lib/credit-records-store.ts`, `src/app/api/credit-transactions/route.ts`, `src/app/api/redeem-codes/redeem/route.ts`, `src/app/api/invitations/me/route.ts`, `src/lib/invitation-service.ts` | The credits tab includes redeem-code input, a `获取兑换码` button, and a per-user invite link. The get-code and recharge buttons open `site_config.redeem_code_mall_url` from `/api/site-config` when configured. Successful redemption calls the server transaction route, updates either `profiles.credits_balance` for credit codes or `profiles.membership_tier`/`membership_expires_at` for membership codes, refreshes the auth profile, and then reloads server credit records. Invite links use `profiles.invite_code`; registrations through `/auth/register?invite=...` create an `invitation_referrals` row and award 50 credits to both inviter and invitee. The credit record store is mounted by this tab itself, not by the profile page parent. |
|
||||
| Orders tab/store | `src/components/profile/orders-tab.tsx`, `src/lib/order-store.ts`, `src/app/api/admin/orders/route.ts` | The order store is mounted by this tab itself, not by the profile page parent, so account/profile navigation does not fetch orders until the user opens the order view. |
|
||||
| Billing guard | `src/components/billing-plan-guard.tsx`, `src/lib/admin-store.ts` |
|
||||
|
||||
## Gallery
|
||||
|
||||
55
scripts/test-navigation-performance-policy.mjs
Normal file
55
scripts/test-navigation-performance-policy.mjs
Normal file
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
await runTest('create page lazy-loads heavy creation panels instead of bundling every mode upfront', () => {
|
||||
const source = read('src/app/create/page.tsx');
|
||||
for (const component of ['TextToImagePanel', 'ImageToImagePanel', 'TextToVideoPanel', 'ImageToVideoPanel', 'ReversePromptPanel']) {
|
||||
assert.match(source, new RegExp(`const\\s+${component}\\s*=\\s*dynamic\\(`), `${component} should be imported through next/dynamic`);
|
||||
}
|
||||
assert.doesNotMatch(source, /import\s+\{\s*TextToImagePanel\s*\}\s+from\s+'@\/components\/create\/text-to-image'/);
|
||||
assert.doesNotMatch(source, /import\s+\{\s*ImageToImagePanel\s*\}\s+from\s+'@\/components\/create\/image-to-image'/);
|
||||
assert.doesNotMatch(source, /import\s+\{\s*TextToVideoPanel\s*\}\s+from\s+'@\/components\/create\/text-to-video'/);
|
||||
assert.doesNotMatch(source, /import\s+\{\s*ImageToVideoPanel\s*\}\s+from\s+'@\/components\/create\/image-to-video'/);
|
||||
});
|
||||
|
||||
await runTest('primary navigation prefetches the core app routes', () => {
|
||||
const source = read('src/components/navbar.tsx');
|
||||
assert.match(source, /router\.prefetch\('\/create'\)/);
|
||||
assert.match(source, /router\.prefetch\('\/gallery'\)/);
|
||||
assert.match(source, /router\.prefetch\('\/profile'\)/);
|
||||
assert.match(source, /prefetch=\{true\}/);
|
||||
});
|
||||
|
||||
await runTest('non-critical visit tracking waits for browser idle time', () => {
|
||||
const source = read('src/components/visit-tracker.tsx');
|
||||
assert.match(source, /requestIdleCallback/);
|
||||
assert.match(source, /keepalive:\s*true/);
|
||||
});
|
||||
|
||||
await runTest('profile account page does not eagerly mount heavy record stores for inactive tabs', () => {
|
||||
const source = read('src/app/profile/page.tsx');
|
||||
assert.doesNotMatch(source, /useCreationHistory\(/);
|
||||
assert.doesNotMatch(source, /useCreditRecords\(/);
|
||||
assert.doesNotMatch(source, /useUserOrders\(/);
|
||||
assert.match(source, /getCreationRecordCount\(/);
|
||||
assert.doesNotMatch(source, /<CreditsTab[^>]*creditRecords=/);
|
||||
assert.doesNotMatch(source, /<OrdersTab[^>]*orders=/);
|
||||
});
|
||||
@@ -1,13 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { TextToImagePanel } from '@/components/create/text-to-image';
|
||||
import { ImageToImagePanel } from '@/components/create/image-to-image';
|
||||
import { TextToVideoPanel } from '@/components/create/text-to-video';
|
||||
import { ImageToVideoPanel } from '@/components/create/image-to-video';
|
||||
import ReversePromptPanel from '@/components/create/reverse-prompt-panel';
|
||||
import { Brush, ImagePlus, Video, Film, Loader2, FileSearch } from 'lucide-react';
|
||||
|
||||
const TYPE_MAP: Record<string, string> = {
|
||||
@@ -22,6 +18,34 @@ const DEFAULT_CREATE_TAB = 'text2img';
|
||||
const CREATE_TAB_STORAGE_KEY = 'miaojing:create-active-tab';
|
||||
const CREATE_TAB_VALUES = new Set(Object.values(TYPE_MAP));
|
||||
|
||||
const CreatePanelFallback = () => (
|
||||
<div className="flex min-h-[360px] items-center justify-center py-16 text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin text-primary" />
|
||||
正在加载创作面板
|
||||
</div>
|
||||
);
|
||||
|
||||
const TextToImagePanel = dynamic(
|
||||
() => import('@/components/create/text-to-image').then(mod => mod.TextToImagePanel),
|
||||
{ ssr: false, loading: CreatePanelFallback },
|
||||
);
|
||||
const ImageToImagePanel = dynamic(
|
||||
() => import('@/components/create/image-to-image').then(mod => mod.ImageToImagePanel),
|
||||
{ ssr: false, loading: CreatePanelFallback },
|
||||
);
|
||||
const TextToVideoPanel = dynamic(
|
||||
() => import('@/components/create/text-to-video').then(mod => mod.TextToVideoPanel),
|
||||
{ ssr: false, loading: CreatePanelFallback },
|
||||
);
|
||||
const ImageToVideoPanel = dynamic(
|
||||
() => import('@/components/create/image-to-video').then(mod => mod.ImageToVideoPanel),
|
||||
{ ssr: false, loading: CreatePanelFallback },
|
||||
);
|
||||
const ReversePromptPanel = dynamic(
|
||||
() => import('@/components/create/reverse-prompt-panel'),
|
||||
{ ssr: false, loading: CreatePanelFallback },
|
||||
);
|
||||
|
||||
function normalizeCreateTab(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
return TYPE_MAP[value] || null;
|
||||
|
||||
@@ -15,9 +15,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import type { ManagedModelConfigResponse, ManagedModelRecommendation, ManagedModelType } from '@/lib/model-config-types';
|
||||
import { useCustomApiKeys } from '@/lib/custom-api-store';
|
||||
import { useCreationHistory, type CreationRecord, isPlaceholder } from '@/lib/creation-history-store';
|
||||
import { useCreditRecords, formatRecordTime } from '@/lib/credit-records-store';
|
||||
import { useUserOrders, formatOrderTime } from '@/lib/order-store';
|
||||
import { type CreationRecord, getCreationRecordCount, isPlaceholder } from '@/lib/creation-history-store';
|
||||
import { readStoredAuth, useAuth } from '@/lib/auth-store';
|
||||
import { useSiteConfig } from '@/lib/site-config';
|
||||
import { CreationDetailDialog } from '@/components/creation-detail-dialog';
|
||||
@@ -106,9 +104,7 @@ export default function ProfilePage() {
|
||||
const [emailVerifyCooldown, setEmailVerifyCooldown] = useState(0);
|
||||
const [sendingEmailCode, setSendingEmailCode] = useState(false);
|
||||
const [verifyingEmail, setVerifyingEmail] = useState(false);
|
||||
const { records: creationRecords } = useCreationHistory();
|
||||
const { records: creditRecords } = useCreditRecords();
|
||||
const { orders } = useUserOrders();
|
||||
const [creationRecordCount, setCreationRecordCount] = useState(0);
|
||||
const membershipEnabled = siteConfig.membershipEnabled !== false;
|
||||
|
||||
const openRedeemCodeMall = () => {
|
||||
@@ -155,6 +151,17 @@ export default function ProfilePage() {
|
||||
return () => window.clearInterval(timer);
|
||||
}, [emailVerifyCooldown]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateCount = () => setCreationRecordCount(getCreationRecordCount());
|
||||
updateCount();
|
||||
window.addEventListener('creation-history-updated', updateCount);
|
||||
window.addEventListener('storage', updateCount);
|
||||
return () => {
|
||||
window.removeEventListener('creation-history-updated', updateCount);
|
||||
window.removeEventListener('storage', updateCount);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Use auth store data directly
|
||||
const profile = {
|
||||
username: user?.username || user?.email?.split('@')[0] || '',
|
||||
@@ -518,7 +525,7 @@ export default function ProfilePage() {
|
||||
<Film className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{creationRecords.length}</p>
|
||||
<p className="text-2xl font-bold">{creationRecordCount}</p>
|
||||
<p className="text-xs text-muted-foreground">创作记录</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -774,12 +781,12 @@ export default function ProfilePage() {
|
||||
|
||||
{/* Credits Tab */}
|
||||
{membershipEnabled && <TabsContent value="credits" className="mt-6">
|
||||
<CreditsTab creditsBalance={profile.credits_balance} creditRecords={creditRecords} />
|
||||
<CreditsTab creditsBalance={profile.credits_balance} />
|
||||
</TabsContent>}
|
||||
|
||||
{/* Orders Tab */}
|
||||
{membershipEnabled && <TabsContent value="orders" className="mt-6">
|
||||
<OrdersTab orders={orders} />
|
||||
<OrdersTab />
|
||||
</TabsContent>}
|
||||
|
||||
{/* Works Tab */}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useTheme } from 'next-themes';
|
||||
@@ -62,17 +62,39 @@ export function Navbar() {
|
||||
const { config: siteConfig, loaded: siteLoaded } = useSiteConfig();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const profileRefreshStartedRef = useRef(false);
|
||||
|
||||
// Wait for client-side hydration before rendering auth-dependent UI
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Refresh profile on mount to pick up admin changes (membership, credits, etc.)
|
||||
useEffect(() => {
|
||||
if (isLoggedIn) {
|
||||
refreshProfile();
|
||||
}
|
||||
const runPrefetch = () => {
|
||||
router.prefetch('/');
|
||||
router.prefetch('/create');
|
||||
router.prefetch('/gallery');
|
||||
router.prefetch('/profile');
|
||||
};
|
||||
const idleCallback = window.requestIdleCallback?.(runPrefetch, { timeout: 1800 });
|
||||
const timer = idleCallback === undefined ? window.setTimeout(runPrefetch, 900) : null;
|
||||
return () => {
|
||||
if (idleCallback !== undefined) window.cancelIdleCallback?.(idleCallback);
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
// Auth store already refreshes on focus/visibility; keep navbar mount refresh idle and one-shot.
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn || profileRefreshStartedRef.current) return;
|
||||
profileRefreshStartedRef.current = true;
|
||||
const runRefresh = () => refreshProfile();
|
||||
const idleCallback = window.requestIdleCallback?.(runRefresh, { timeout: 2500 });
|
||||
const timer = idleCallback === undefined ? window.setTimeout(runRefresh, 1200) : null;
|
||||
return () => {
|
||||
if (idleCallback !== undefined) window.cancelIdleCallback?.(idleCallback);
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
};
|
||||
}, [isLoggedIn, refreshProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -139,6 +161,7 @@ export function Navbar() {
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
prefetch={true}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200',
|
||||
isActive
|
||||
@@ -212,6 +235,7 @@ export function Navbar() {
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
prefetch={true}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors',
|
||||
@@ -276,6 +300,7 @@ export function Navbar() {
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
prefetch={true}
|
||||
className={cn(
|
||||
'flex min-h-12 flex-col items-center justify-center gap-1 rounded-xl text-[11px] font-semibold transition-colors',
|
||||
isActive ? 'bg-primary/12 text-primary' : 'text-muted-foreground'
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { Coins, Copy, CreditCard, Crown, ExternalLink, Gift, Loader2, Ticket, TrendingUp, Users } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useAuth } from '@/lib/auth-store';
|
||||
import { formatRecordTime } from '@/lib/credit-records-store';
|
||||
import { formatRecordTime, useCreditRecords } from '@/lib/credit-records-store';
|
||||
import { useSiteConfig } from '@/lib/site-config';
|
||||
|
||||
type CreditRecord = {
|
||||
@@ -22,7 +22,6 @@ type CreditRecord = {
|
||||
|
||||
type CreditsTabProps = {
|
||||
creditsBalance: number;
|
||||
creditRecords: CreditRecord[];
|
||||
};
|
||||
|
||||
type InvitationReferral = {
|
||||
@@ -40,9 +39,10 @@ const membershipTierLabels: Record<string, string> = {
|
||||
enterprise: '企业会员',
|
||||
};
|
||||
|
||||
export default function CreditsTab({ creditsBalance, creditRecords }: CreditsTabProps) {
|
||||
export default function CreditsTab({ creditsBalance }: CreditsTabProps) {
|
||||
const { accessToken, refreshProfile, updateProfile } = useAuth();
|
||||
const { config: siteConfig } = useSiteConfig();
|
||||
const { records: creditRecords } = useCreditRecords();
|
||||
const [redeemCode, setRedeemCode] = useState('');
|
||||
const [redeeming, setRedeeming] = useState(false);
|
||||
const [serverRecords, setServerRecords] = useState<CreditRecord[]>([]);
|
||||
|
||||
@@ -3,20 +3,11 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Receipt } from 'lucide-react';
|
||||
import { formatOrderTime } from '@/lib/order-store';
|
||||
import { formatOrderTime, useUserOrders } from '@/lib/order-store';
|
||||
|
||||
type UserOrder = {
|
||||
id: string;
|
||||
productName: string;
|
||||
orderNo: string;
|
||||
createdAt: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
};
|
||||
export default function OrdersTab() {
|
||||
const { orders } = useUserOrders();
|
||||
|
||||
type OrdersTabProps = { orders: UserOrder[] };
|
||||
|
||||
export default function OrdersTab({ orders }: OrdersTabProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
type IdleCallbackHandle = ReturnType<Window['requestIdleCallback']>;
|
||||
|
||||
/**
|
||||
* Tracks site visits by calling /api/site-stats on page load.
|
||||
* Uses sessionStorage to count only once per browser session.
|
||||
@@ -18,13 +20,25 @@ export function VisitTracker() {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/api/site-stats', { method: 'POST' })
|
||||
.then(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const trackVisit = () => {
|
||||
fetch('/api/site-stats', { method: 'POST', keepalive: true })
|
||||
.then(() => {
|
||||
sessionStorage.setItem('visit_tracked', '1');
|
||||
}
|
||||
})
|
||||
.catch(() => { /* non-critical */ });
|
||||
})
|
||||
.catch(() => { /* non-critical */ });
|
||||
};
|
||||
|
||||
let idleHandle: IdleCallbackHandle | null = null;
|
||||
let timer: number | null = null;
|
||||
if (typeof window.requestIdleCallback === 'function') {
|
||||
idleHandle = window.requestIdleCallback(trackVisit, { timeout: 2500 });
|
||||
} else {
|
||||
timer = window.setTimeout(trackVisit, 1200);
|
||||
}
|
||||
return () => {
|
||||
if (idleHandle !== null) window.cancelIdleCallback?.(idleHandle);
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -255,6 +255,10 @@ export function getCreationRecords(): CreationRecord[] {
|
||||
return loadRecords();
|
||||
}
|
||||
|
||||
export function getCreationRecordCount(): number {
|
||||
return loadRecords().length;
|
||||
}
|
||||
|
||||
export async function deleteCreationRecord(id: string): Promise<void> {
|
||||
const token = getAuthToken();
|
||||
if (token) {
|
||||
|
||||
Reference in New Issue
Block a user