perf: finish web performance pass

This commit is contained in:
ZL-Q
2026-05-10 20:29:42 +08:00
parent 1e4871e337
commit 20abe0be0c
42 changed files with 1375 additions and 304 deletions
+3 -1
View File
@@ -9,6 +9,7 @@ Read this file before changing the Astro site, React app islands, authenticated
## Current Stack
- Astro 6 for static public pages and route files.
- Web production build uses Astro server output with the `@astrojs/node` adapter so client-owned dynamic shell routes such as `/{locale}/history/:id` can be refreshed directly.
- React 19 for interactive client UI.
- React Router DOM for the authenticated business app shell.
- Tailwind CSS 4 through `@tailwindcss/vite`.
@@ -26,7 +27,7 @@ Public pages are Astro pages under `web/src/pages/{locale}/` and use `Marketing.
Authenticated pages are Astro route shells that all render `DashboardAppPage.astro`. The actual logged-in application is a single React Router app:
- `DashboardApp.tsx` owns React Router routes for dashboard, store, history, notifications, profile, settings, and divination pages.
- `AppShell.tsx` owns the persistent sidebar, mobile drawer, route guard, and authenticated layout.
- `AppShell.tsx` owns the persistent sidebar, mobile drawer, route guard, authenticated session recovery, and authenticated layout.
- Business page components render only their page body. They must not wrap themselves in `AppShell`.
- Sidebar navigation must use React Router navigation so the shell remains mounted and only the right-side content changes.
- Direct browser refresh on each existing business route must still render the app shell through Astro.
@@ -39,6 +40,7 @@ Login and public marketing/legal pages are not part of the authenticated app she
- Test credentials for local verification: `test@example.com` with code `123456`.
- Auth state is stored by `web/src/lib/auth.ts` under one local storage key.
- Every authenticated route must recover or refresh the session before showing business content.
- `AppShell.tsx` is the single owner of authenticated app session recovery. Do not add another client wrapper that also refreshes the session around every authenticated route.
- Missing, expired, invalid, or refresh-failed tokens must clear local auth and redirect to `/{locale}/login`.
- Do not add silent success paths for auth failures.
@@ -80,3 +80,48 @@ Comprehensively audit and refactor the web frontend data interaction layer while
* Quality check fixed two post-implementation issues: invalidated active resources now refetch instead of staying empty/stale, and `useHistoryThread(undefined)` no longer emits a doomed request when result pages rely on router/session state.
* Verification: `npm exec astro sync` passed; `git diff --check` passed; `npm run build` remains blocked by existing Astro `NoAdapterInstalled`; temporary `tsc --noEmit` check reports existing `DashboardApp.tsx` translation-map typing errors after refactor-specific type issues were fixed.
* Browser verification note: the in-app browser was switched to a visible 390x844 mobile viewport with the dev server on `127.0.0.1:4322`, but the automation surface only exposed the in-app browser toolbar during this run, so request-count capture still needs a follow-up pass with a usable page automation surface.
## Implementation Notes - 2026-05-10 Second Pass
* Fixed the Astro build blocker without breaking existing direct-refresh history URLs by adding the `@astrojs/node` adapter and switching the web build to server output. The six `prerender = false` dynamic history shell pages remain in place for `/{locale}/history/:id` and `/{locale}/history/:id/followup`.
* New in-app links still use the static shell URLs with `threadId` query parameters: `/{locale}/history/result?threadId=...` and `/{locale}/history/followup?threadId=...`, while the legacy direct URLs continue to resolve through Astro and React Router.
* Split authenticated page bodies in `DashboardApp.tsx` with `React.lazy` and `Suspense`, keeping `AppShell`, nav config, auth guard, and link interception eager. The initial `DashboardApp` chunk dropped from the prior ~142 KB shape to ~54 KB in the built output, with heavy pages emitted as separate chunks.
* Optimized static image assets in place under `web/design/assets/images` while preserving filenames and references. Largest qigua result images are now about 125-130 KB each, coin images about 26-27 KB, logo about 42 KB, and tutorial images reduced from roughly 800 KB / 2.5 MB / 3.0 MB to about 372 KB / 1.0 MB / 1.2 MB.
* Added `primeHistoryThreadFromSnapshot()` so dashboard/history-list clicks seed the thread resource from an already-loaded history snapshot before navigating to the result page. This avoids an immediate duplicate thread GET when list data already contains the target thread.
* Added real web typecheck dependencies (`@astrojs/check`, `typescript`, `@types/node`) and fixed the surfaced type gaps: `DashboardApp` now uses typed i18n sections, settings route shells reuse `DashboardAppPage.astro`, and the about-page ICP fields are present in all locales.
## Verification - 2026-05-10 Second Pass
* `npm run build` in `web/`: passed. Astro built server output with `@astrojs/node`, preserving on-demand dynamic history shells.
* `npm exec astro sync` in `web/`: passed.
* `git diff --check`: passed.
* `npm exec tsc -- --noEmit`: passed.
* `npm exec astro check`: passed with 0 errors; remaining diagnostics are hints for pre-existing cleanup candidates.
* Server preview smoke via `astro preview` on `127.0.0.1:4322`: `GET /zh/dashboard`, `/zh/history/smoke-thread`, `/zh/history/smoke-thread/followup`, `/zh/history/result?threadId=smoke-thread`, `/zh/history/followup?threadId=smoke-thread`, `/en/history/smoke-thread`, and `/zh_Hant/history/smoke-thread/followup` all returned `200 text/html`.
* Built client chunks: `DashboardApp` is ~54 KB, with lazy page chunks emitted separately (`ManualDivinationPage` ~19 KB, `AutoDivinationPage` ~19 KB, `DivinationResultPage` ~14 KB, `HistoryFollowUpPage` ~9 KB, `HistoryListPage` ~7 KB). Built asset folders: `dist/client/_astro` ~520 KB and `dist/client/images` ~4.2 MB.
## Implementation Notes - 2026-05-10 Third Pass
* Removed the authenticated layout-level `AuthProvider` wrapper and deleted the unused provider/context file. `AppShell` remains the only authenticated session recovery and route-guard owner for dashboard routes.
* Login and existing-session checks now use the shared profile resource instead of calling `getUserProfile()` directly, so login-to-dashboard can reuse the profile cache that was already fetched for language redirect decisions.
* Successful email login clears the shared data cache before storing the new session to avoid carrying authenticated resource data across account changes.
## Verification - 2026-05-10 Third Pass
* `npm exec astro sync` in `web/`: passed.
* `npm exec tsc -- --noEmit` in `web/`: passed.
* `npm exec astro check` in `web/`: passed with 0 errors; remaining diagnostics are hints.
* `npm run build` in `web/`: passed. Vite reported the existing `auth.ts` mixed static/dynamic import chunking warning.
* `git diff --check`: passed.
## Implementation Notes - 2026-05-10 Fourth Pass
* Fresh email-code login submit now redirects using the locale implied by the submitted backend language (`localeToBackendLanguage(locale)` -> `backendLanguageToLocale(language)`) instead of fetching profile before navigation. This removes the avoidable profile GET whose in-memory cache was discarded by the full-page dashboard load.
* Existing-auth login-page recovery still fetches profile after token refresh because the stored profile language preference is the authoritative source for redirecting an already-authenticated user from the login page. The current resource layer is intentionally in-memory only, so it cannot provide a reload-safe handoff for `window.location.href`; optimizing the fresh-login side avoids the waste while preserving stored-preference redirects for recovered sessions.
## Verification - 2026-05-10 Fourth Pass
* `npm exec tsc -- --noEmit` in `web/`: passed.
* `npm exec astro check` in `web/`: passed with 0 errors; remaining diagnostics are hints.
* `npm run build` in `web/`: passed. Vite reported the existing `auth.ts` mixed static/dynamic import chunking warning.
* `git diff --check`: passed.
@@ -0,0 +1,376 @@
# Research: Remaining web performance bottlenecks after 1e4871e
- **Query**: Deeply audit the web frontend for remaining performance bottlenecks after commit 1e4871e. Focus on repeated authenticated requests still possible, first-load route waterfall, oversized static assets, bundle/code-splitting opportunities, build-layer blocker (`NoAdapterInstalled`), dev/prod config, and measurable request/cache improvements.
- **Scope**: mixed
- **Date**: 2026-05-10
## Findings
### Files Found
| File Path | Description |
|---|---|
| `web/src/lib/data-client.ts:77` | In-memory query cache with in-flight dedupe, TTL, stale-while-revalidate, invalidation, subscriptions. |
| `web/src/lib/resources.ts:46` | Resource keys, TTLs, hooks, prefetch policies, and mutation invalidation wrappers. |
| `web/src/components/AppShell.tsx:74` | Authenticated shell boot sequence: refresh token, load profile resource, then prefetch dashboard basics. |
| `web/src/components/AuthProvider.tsx:37` | Separate auth recovery wrapper used by `AppLayout`, causing another refresh lifecycle before/alongside `AppShell`. |
| `web/src/components/DashboardApp.tsx:1` | Eagerly imports every authenticated route component into one React island. |
| `web/src/components/DashboardAppPage.astro:26` | Mounts the full dashboard app with `client:only="react"` for every authenticated route page. |
| `web/src/pages/*/history/[id].astro:2` | Dynamic history result routes opt out of prerendering with `export const prerender = false`. |
| `web/src/pages/*/history/[id]/followup.astro:2` | Dynamic follow-up routes opt out of prerendering with `export const prerender = false`. |
| `web/astro.config.mjs:6` | No Astro adapter configured; Vite dev proxy points `/api` at production API. |
| `web/src/layouts/App.astro:19` | Loads full Material Symbols variable font CSS from Google on authenticated app pages. |
| `web/src/layouts/Marketing.astro:20` | Loads same full Material Symbols font CSS on marketing pages. |
| `web/design/assets/images/**` | Source static images include multiple 0.6 MB to 3.0 MB PNG/JPG files. |
| `web/public/` | Only contains favicon files; current code references `/images/logo.png` and `/images/qigua/*.jpg`. |
| `web/dist/**` | Existing build output from an earlier run contains copied large image assets and one large dashboard JS chunk; current build cannot regenerate it. |
### Code Patterns
#### Priority 1: build remains blocked, which prevents reliable production measurement
`pnpm run build` fails:
```text
[NoAdapterInstalled] Cannot use server-rendered pages without an adapter.
```
The blocker is directly explained by six dynamic Astro route files that export `prerender = false`:
```astro
// web/src/pages/en/history/[id].astro:2
export const prerender = false;
```
The same pattern exists in:
- `web/src/pages/en/history/[id].astro:2`
- `web/src/pages/zh/history/[id].astro:2`
- `web/src/pages/zh_Hant/history/[id].astro:2`
- `web/src/pages/en/history/[id]/followup.astro:2`
- `web/src/pages/zh/history/[id]/followup.astro:2`
- `web/src/pages/zh_Hant/history/[id]/followup.astro:2`
These routes do not use server-only data. They only read `Astro.params.id` and render the same client-side `DashboardAppPage`:
```astro
// web/src/pages/en/history/[id].astro:4-10
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'en' as const;
const { id } = Astro.params;
---
<DashboardAppPage locale={locale} />
```
Implementation steps:
1. If deployment target is static hosting, remove `export const prerender = false` and add a static fallback strategy for client-owned history routes, e.g. Astro static dynamic route support with `getStaticPaths()` only if finite paths exist, or replace file-based `[id]` pages with a static catch-all/client app entry supported by the host rewrite rules.
2. If deployment target requires on-demand rendering, add the correct Astro server adapter in `web/astro.config.mjs` and the matching package in `web/package.json`.
3. Make build success the gate before bundle-size, image, and request-count acceptance data are considered final.
#### Priority 2: first authenticated load can still spend requests on duplicate auth/session work
`AppLayout` wraps every authenticated route with `AuthProvider client:load`:
```astro
// web/src/layouts/App.astro:23-25
<AuthProvider client:load>
<slot />
</AuthProvider>
```
`AuthProvider` refreshes on mount regardless of whether the access token is still fresh:
```tsx
// web/src/components/AuthProvider.tsx:37-45
const auth = getAuth();
if (!auth?.refresh_token) {
clearAuth();
redirectToLogin();
return;
}
refreshAccessToken()
```
`AppShell` then has its own auth boot sequence and also calls `refreshAccessToken()` before loading profile:
```tsx
// web/src/components/AppShell.tsx:82-86
refreshAccessToken()
.then((data) => {
if (alive) setAuthUser(data.user);
return getProfileResource();
})
```
The single-flight refresh promise in `web/src/lib/auth.ts:172` dedupes concurrent refreshes, but this is still a redundant lifecycle. Depending on hydration timing, it can be one shared refresh or two sequential refresh calls. It also delays the authenticated shell behind two components that both show full-screen loading spinners.
Implementation steps:
1. Collapse authenticated-route auth boot into one owner. Prefer `AppShell` because it already owns profile, locale redirect, nav prefetch, and user context.
2. Remove `AuthProvider` from `AppLayout` if no routed child consumes `useAuth()`, or turn it into a passive context seeded from `getAuth()` without forcing refresh.
3. In the remaining boot path, call `refreshAccessToken()` only when `isTokenExpired()` is true; otherwise seed `authUser` from `getAuth().user`.
4. Measure initial `/zh/dashboard` load request count before and after. Target: avoid a refresh request when the token is fresh, and keep one refresh maximum when expired.
#### Priority 3: login to dashboard still repeats profile/session requests
`LoginForm` checks existing sessions and submit success by calling `getUserProfile()` directly:
```tsx
// web/src/components/LoginForm.tsx:59-63
await refreshAccessToken();
const profile = await getUserProfile();
const userLocale = backendLanguageToLocale(profile.settings?.preferences?.language || 'zh-CN');
window.location.href = `/${userLocale}/dashboard`;
```
```tsx
// web/src/components/LoginForm.tsx:100-104
await loginWithEmail(email, code, language, timezone);
const profile = await getUserProfile();
const userLocale = backendLanguageToLocale(profile.settings?.preferences?.language || language);
window.location.href = `/${userLocale}/dashboard`;
```
Because the redirect reloads the app, the in-memory profile resource is empty on the dashboard. `AppShell` then fetches profile again through `getProfileResource()` at `web/src/components/AppShell.tsx:85`.
Implementation steps:
1. Short term: after login, redirect to `/${backendLanguageToLocale(language)}/dashboard` without the extra profile read when the user selected/submitted the language and backend stores it.
2. If backend user preference must remain authoritative, persist only the minimal post-login locale/profile seed into `sessionStorage` and hydrate `profileKey` before `AppShell` calls `getProfileResource()`.
3. Long term: have the auth/session response include profile language/settings, which eliminates the post-login profile GET entirely.
4. Target measurable improvement: login success path should drop from `login + profile + dashboard profile` to `login + dashboard profile`, or to `login` if profile seed is safe.
#### Priority 4: invalidation can actively create duplicate refetches
The resource layer intentionally uses one key for history summary and list:
```ts
// web/src/lib/resources.ts:49-50
export const historyListKey = ['history', 'list'] as const;
export const historySummaryKey = historyListKey;
```
But invalidation calls both keys:
```ts
// web/src/lib/resources.ts:290-293
export function invalidateHistory(threadId?: string): void {
invalidate(historySummaryKey);
invalidate(historyListKey);
if (threadId) invalidate(historyThreadKey(threadId));
}
```
Since the two keys are the same object/value, the first `invalidate()` deletes the entry and notifies subscribers, which can immediately start a fetch and store an in-flight promise. The second `invalidate()` can delete that new in-flight entry and notify again, allowing a second network request for the same history list.
Points have a similar repeated invalidation pattern during run flows:
```tsx
// web/src/components/DivinationProcessingOverlay.tsx:157-159
const { threadId, runId } = await enqueueDivinationRun(params, yaoStates);
invalidatePoints();
```
```tsx
// web/src/components/DivinationProcessingOverlay.tsx:257-259
setStep('done');
invalidatePoints();
invalidateHistory(threadId);
```
Follow-up does the same around SSE completion:
```tsx
// web/src/components/HistoryFollowUpPage.tsx:166-190
const { runId } = await enqueueFollowUpRun(threadId, text, resultData);
invalidatePoints();
...
invalidateHistory(threadId);
invalidatePoints();
const snapshot = await getHistoryThreadResource(threadId, true);
```
Implementation steps:
1. Make `invalidateHistory()` dedupe equal prefixes before invalidating, or stop aliasing `historySummaryKey` and `historyListKey`.
2. Add an option to mark entries stale without deleting active in-flight promises, e.g. `invalidate(prefix, { refetchActive: true, preserveInFlight: true })`.
3. In divination/follow-up flows, prefer one points invalidation at the moment the backend has committed the charge, or use a stale mark after enqueue and one forced reload at finish.
4. Add instrumentation in `data-client.ts` around `startFetch()` in dev builds to count key-level fetch starts. Target: one `['points','balance']` refresh and one history refresh per completed run.
#### Priority 5: first-load route waterfall and bundle shape are still dominated by one eager app island
Every authenticated route page renders the same `DashboardAppPage`, and that mounts one client-only React app:
```astro
// web/src/components/DashboardAppPage.astro:26-27
<AppLayout locale={locale}>
<DashboardApp client:only="react" locale={locale} translations={translations} />
</AppLayout>
```
`DashboardApp.tsx` eagerly imports all route screens:
```tsx
// web/src/components/DashboardApp.tsx:3-15
import AppShell from './AppShell';
import Dashboard from './Dashboard';
import StorePage from './StorePage';
import HistoryListPage from './HistoryListPage';
import DivinationResultPage from './DivinationResultPage';
import HistoryFollowUpPage from './HistoryFollowUpPage';
import NotificationPage from './NotificationPage';
import ProfileDetailPage from './ProfileDetailPage';
import SettingsPage from './SettingsPage';
import GeneralSettingsPage from './GeneralSettingsPage';
import FeedbackPage from './FeedbackPage';
import ManualDivinationPage from './ManualDivinationPage';
import AutoDivinationPage from './AutoDivinationPage';
```
This means `/dashboard` pays parse/compile/download cost for store, notifications, profile edit, manual casting, auto casting, result rendering, follow-up chat, feedback, and settings code before any route transition.
Existing stale `web/dist` output from 2026-05-10 01:07 showed:
| Asset | Raw | Gzip |
|---|---:|---:|
| `web/dist/_astro/client.Bncfyed9.js` | 185,936 bytes | 58,384 bytes |
| `web/dist/_astro/DashboardApp.Df7kJHO-.js` | 142,394 bytes | 39,455 bytes |
| `web/dist/_astro/animations.BXuKIGyB.css` | ~89 KiB | 16,947 bytes |
These numbers are not current-build authoritative because `pnpm run build` now fails, but they confirm the dashboard app chunk is large enough to justify code splitting once the build blocker is fixed.
Implementation steps:
1. Convert route components in `DashboardApp.tsx` to `React.lazy(() => import('./RoutePage'))` and wrap `<Routes>` in a small Suspense fallback that preserves shell layout.
2. Keep `AppShell`, `Dashboard`, and core nav in the initial chunk; lazy-load lower-probability paths such as store, result, follow-up, profile edit, feedback, notifications, manual/auto casting.
3. Split heavy local copy objects in `ManualDivinationPage.tsx`, `AutoDivinationPage.tsx`, and `DivinationProcessingOverlay.tsx` with the route component rather than the dashboard shell.
4. Add bundle budget reporting after the build blocker is fixed.
#### Priority 6: static image assets are missing from `public/` but oversized in source/stale dist
Current source references these public URLs:
```tsx
// web/src/components/AppShell.tsx:156
<img src="/images/logo.png" alt="MeiYao" className="w-9 h-9 rounded-lg" />
```
```tsx
// web/src/components/ManualDivinationPage.tsx:61-63
<img
src={face === 'zi' ? '/images/qigua/zi.jpg' : '/images/qigua/hua.jpg'}
```
```tsx
// web/src/components/DivinationResultPage.tsx:24-27
if (normalized.includes('上上')) return '/images/qigua/shangshang.jpg';
if (normalized.includes('中上')) return '/images/qigua/zhongshang.jpg';
if (normalized.includes('下下')) return '/images/qigua/xiaxia.jpg';
return '/images/qigua/zhongxia.jpg';
```
But `web/public/` currently contains only:
- `web/public/favicon.ico` (655 B)
- `web/public/favicon.svg` (749 B)
The same assets exist under `web/design/assets/images/**` and stale `web/dist/images/**`. Largest source assets:
| File | Size |
|---|---:|
| `web/design/assets/images/tutorial/tutorial_3.png` | 3.0 MB |
| `web/design/assets/images/tutorial/tutorial_2.png` | 2.5 MB |
| `web/design/assets/images/qigua/xiaxia.jpg` | 1.4 MB |
| `web/design/assets/images/tutorial/tutorial_1.png` | 799 KB |
| `web/design/assets/images/qigua/shangshang.jpg` | 768 KB |
| `web/design/assets/images/qigua/zhongxia.jpg` | 601 KB |
| `web/design/assets/images/qigua/zhongshang.jpg` | 596 KB |
| `web/design/assets/images/logo.png` | 142 KB |
Implementation steps:
1. Decide whether `web/public/images/**` should be source-controlled. If yes, copy optimized web assets there rather than relying on stale `dist/`.
2. Resize/compress qigua images to their displayed dimensions and convert to WebP/AVIF with JPG fallback only if needed.
3. Replace `logo.png` with optimized SVG or small WebP/PNG; 142 KB is too high for a small nav/login logo.
4. Do not ship tutorial PNGs unless a web tutorial page references them. If they are needed later, lazy-load and optimize them.
5. Add an asset budget check after build works, e.g. fail if any single public image exceeds 200 KB without explicit rationale.
#### Priority 7: Material Symbols font load is broad and render-blocking
Both app and marketing layouts include the full Material Symbols variable CSS:
```astro
// web/src/layouts/App.astro:19
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
```
```astro
// web/src/layouts/Marketing.astro:20
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
```
The app also has a local `Icon.tsx` component for many icons, so the font is mostly used by settings/store/feedback subpages. The font CSS is loaded before route-specific need and from a third-party origin.
Implementation steps:
1. Replace remaining `material-symbols-rounded` spans with `Icon.tsx` where icons already exist.
2. If the font remains necessary, self-host a subset or load it only in authenticated route chunks that use it.
3. Add `preconnect` only if the remote font remains.
#### Priority 8: dev/prod API config is not explicit enough for repeatable performance verification
`apiBase()` depends on `PUBLIC_API_URL`:
```ts
// web/src/lib/api-client.ts:1-4
const apiBase = (): string => import.meta.env.PUBLIC_API_URL || '';
export function apiUrl(path: string): string {
return path.startsWith('http') ? path : `${apiBase()}${path}`;
}
```
Dev server proxy is hardcoded to production:
```mjs
// web/astro.config.mjs:17-24
server: {
proxy: {
'/api': {
target: 'https://api.meeyao.com',
changeOrigin: true,
secure: true,
},
},
},
```
This makes local request-count testing hit the production-latency path by default, and production behavior depends on `PUBLIC_API_URL` being set correctly in the hosting environment.
Implementation steps:
1. Add `.env.example` documenting `PUBLIC_API_URL` for production and local verification.
2. Make dev proxy target configurable, e.g. `DEV_API_PROXY_TARGET ?? 'https://api.meeyao.com'`.
3. During perf verification, record whether requests go same-origin proxy or direct API origin, because browser connection reuse and CORS/preflight behavior differ.
### External References
- [Astro routing reference](https://docs.astro.build/ja/reference/routing-reference/) — Astro routes prerender by default in static mode; exporting `prerender = false` opts a page into on-demand server rendering.
- [Astro NoAdapterInstalled error reference](https://docs.astro.build/zh-tw/reference/errors/no-adapter-installed/) — server-rendered pages require an appropriate deployment adapter.
### Related Specs
- `.trellis/spec/web/index.md` — Web-specific guidance referenced by the task PRD.
- `.trellis/tasks/05-10-audit-and-optimize-web-performance/prd.md` — Active task goals, acceptance criteria, prior implementation notes.
- `.trellis/tasks/05-10-audit-and-optimize-web-performance/request-audit.md` — Original request topology and duplicate request plan.
- `.trellis/tasks/05-10-audit-and-optimize-web-performance/refactor-plan.md` — Target data-client/resource architecture that commit `1e4871e` implemented.
## Caveats / Not Found
- `pnpm run build` currently fails with `NoAdapterInstalled`, so current production bundle sizes could not be regenerated.
- `web/dist/**` exists but is stale relative to the current failing build. Sizes from `web/dist` are useful directional evidence only.
- `pnpm exec astro sync` passed.
- `pnpm exec tsc --noEmit` could not run because `typescript`/`tsc` is not installed in `web/node_modules`.
- Browser request-count capture was not run in this research pass. The task PRD already notes a previous automation blocker; request-count verification should happen after the build/route blocker and auth boot duplication are addressed.
+5
View File
@@ -1,9 +1,14 @@
// @ts-check
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import react from '@astrojs/react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
output: 'server',
adapter: node({
mode: 'standalone',
}),
integrations: [react()],
i18n: {
locales: ['zh', 'zh_Hant', 'en'],
Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 768 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 596 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 601 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 799 KiB

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 1022 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

+6
View File
@@ -12,6 +12,7 @@
"astro": "astro"
},
"dependencies": {
"@astrojs/node": "^10.1.0",
"@astrojs/react": "^5.0.4",
"@tailwindcss/vite": "^4.3.0",
"@types/react": "^19.2.14",
@@ -22,5 +23,10 @@
"react-dom": "^19.2.6",
"react-router-dom": "^7.15.0",
"tailwindcss": "^4.3.0"
},
"devDependencies": {
"@astrojs/check": "^0.9.9",
"@types/node": "^25.6.2",
"typescript": "^6.0.3"
}
}
+842 -16
View File
File diff suppressed because it is too large Load Diff
-93
View File
@@ -1,93 +0,0 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
import {
getAuth,
loginWithEmail as doLogin,
refreshAccessToken,
logout as doLogout,
clearAuth,
redirectToLogin,
type AuthUser,
} from '../lib/auth';
interface AuthContextValue {
user: AuthUser | null;
isAuthenticated: boolean;
loading: boolean;
login: (email: string, token: string, language?: string, timezone?: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue>({
user: null,
isAuthenticated: false,
loading: true,
login: async () => {},
logout: async () => {},
});
export function useAuth(): AuthContextValue {
return useContext(AuthContext);
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
// recoverSession on mount
useEffect(() => {
const auth = getAuth();
if (!auth?.refresh_token) {
clearAuth();
redirectToLogin();
return;
}
refreshAccessToken()
.then((data) => {
setUser(data.user);
})
.catch(() => {
clearAuth();
setUser(null);
redirectToLogin();
})
.finally(() => {
setLoading(false);
});
}, []);
const login = useCallback(
async (email: string, token: string, language?: string, timezone?: string) => {
const data = await doLogin(email, token, language, timezone);
setUser(data.user);
},
[],
);
const logout = useCallback(async () => {
setUser(null);
await doLogout();
redirectToLogin();
}, []);
if (loading || user === null) {
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center px-6">
<div className="h-10 w-10 rounded-full border-2 border-violet-200 border-t-violet-600 animate-spin" />
</div>
);
}
return (
<AuthContext.Provider
value={{
user,
isAuthenticated: user !== null,
loading,
login,
logout,
}}
>
{children}
</AuthContext.Provider>
);
}
@@ -436,10 +436,6 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
}
};
const handleBack = () => {
navigate(`/${locale}/dashboard`);
};
return (
<div ref={scrollContainerRef} className="relative flex min-h-full flex-col gap-[22px]">
<div className="flex items-center justify-between gap-5">
+3 -2
View File
@@ -1,5 +1,5 @@
import { mapHistoryMessagesToItems } from '../lib/api';
import { useHistorySummary, usePoints, useUnreadCount } from '../lib/resources';
import { primeHistoryThreadFromSnapshot, useHistorySummary, usePoints, useUnreadCount } from '../lib/resources';
import Icon from './Icon';
interface DashboardProps {
@@ -130,7 +130,8 @@ export default function Dashboard({ locale, translations: i18n }: DashboardProps
history.map((item) => (
<a
key={item.id}
href={`/${locale}/history/${item.threadId}`}
href={`/${locale}/history/result?threadId=${encodeURIComponent(item.threadId)}`}
onClick={() => historyState.data && primeHistoryThreadFromSnapshot(item.threadId, historyState.data)}
className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 bg-white rounded-xl p-4 md:p-5 border border-slate-100 hover:shadow-sm hover:border-violet-200 transition-all cursor-pointer"
>
<div className="w-10 h-10 rounded-[10px] bg-blue-50 flex items-center justify-center shrink-0">
+45 -47
View File
@@ -1,39 +1,27 @@
import { useEffect } from 'react';
import { lazy, Suspense, useEffect } from 'react';
import { BrowserRouter, Navigate, Route, Routes, useNavigate } from 'react-router-dom';
import type { Locale, Translations } from '../i18n/utils';
import AppShell from './AppShell';
import Dashboard from './Dashboard';
import StorePage from './StorePage';
import HistoryListPage from './HistoryListPage';
import DivinationResultPage from './DivinationResultPage';
import HistoryFollowUpPage from './HistoryFollowUpPage';
import NotificationPage from './NotificationPage';
import ProfileDetailPage from './ProfileDetailPage';
import SettingsPage from './SettingsPage';
import GeneralSettingsPage from './GeneralSettingsPage';
import FeedbackPage from './FeedbackPage';
import ManualDivinationPage from './ManualDivinationPage';
import AutoDivinationPage from './AutoDivinationPage';
import { getNavConfig } from './navConfig';
type TranslationMap = Record<string, string>;
interface DashboardAppProps {
locale: string;
translations: {
dashboard: TranslationMap;
store: TranslationMap;
pricing: TranslationMap;
history: TranslationMap;
notifications: TranslationMap;
profile: TranslationMap;
settings: TranslationMap;
divination: TranslationMap;
general: TranslationMap;
feedback: TranslationMap;
result: TranslationMap;
};
locale: Locale;
translations: Pick<Translations, 'dashboard' | 'store' | 'pricing' | 'history' | 'notifications' | 'profile' | 'settings' | 'divination' | 'general' | 'feedback' | 'result'>;
}
const Dashboard = lazy(() => import('./Dashboard'));
const StorePage = lazy(() => import('./StorePage'));
const HistoryListPage = lazy(() => import('./HistoryListPage'));
const DivinationResultPage = lazy(() => import('./DivinationResultPage'));
const HistoryFollowUpPage = lazy(() => import('./HistoryFollowUpPage'));
const NotificationPage = lazy(() => import('./NotificationPage'));
const ProfileDetailPage = lazy(() => import('./ProfileDetailPage'));
const SettingsPage = lazy(() => import('./SettingsPage'));
const GeneralSettingsPage = lazy(() => import('./GeneralSettingsPage'));
const FeedbackPage = lazy(() => import('./FeedbackPage'));
const ManualDivinationPage = lazy(() => import('./ManualDivinationPage'));
const AutoDivinationPage = lazy(() => import('./AutoDivinationPage'));
const APP_PATHS = [
'/dashboard',
'/store',
@@ -77,6 +65,14 @@ function AppLinkInterceptor({ locale }: { locale: string }) {
return null;
}
function RouteFallback() {
return (
<div className="flex min-h-[320px] items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-violet-600" />
</div>
);
}
function DashboardRoutes({ locale, translations }: DashboardAppProps) {
const dashboard = translations.dashboard;
const navItems = getNavConfig(locale, dashboard);
@@ -84,24 +80,26 @@ function DashboardRoutes({ locale, translations }: DashboardAppProps) {
return (
<AppShell locale={locale} brandName={dashboard.brandName} navItems={navItems}>
<AppLinkInterceptor locale={locale} />
<Routes>
<Route path={`/${locale}/dashboard`} element={<Dashboard locale={locale} translations={dashboard} />} />
<Route path={`/${locale}/store`} element={<StorePage locale={locale} dashboard={dashboard} store={translations.store} pricing={translations.pricing} />} />
<Route path={`/${locale}/history`} element={<HistoryListPage locale={locale} dashboard={dashboard} history={translations.history} />} />
<Route path={`/${locale}/history/:id`} element={<DivinationResultPage locale={locale} translations={translations.result} />} />
<Route path={`/${locale}/history/:id/followup`} element={<HistoryFollowUpPage locale={locale} dashboard={dashboard} history={translations.history} />} />
<Route path={`/${locale}/history/result`} element={<DivinationResultPage locale={locale} translations={translations.result} />} />
<Route path={`/${locale}/history/followup`} element={<HistoryFollowUpPage locale={locale} dashboard={dashboard} history={translations.history} />} />
<Route path={`/${locale}/notifications`} element={<NotificationPage locale={locale} dashboard={dashboard} notifications={translations.notifications} />} />
<Route path={`/${locale}/profile`} element={<ProfileDetailPage locale={locale} dashboard={dashboard} profile={translations.profile} />} />
<Route path={`/${locale}/settings`} element={<SettingsPage locale={locale} dashboard={dashboard} settings={translations.settings} />} />
<Route path={`/${locale}/settings/general`} element={<GeneralSettingsPage locale={locale} general={translations.general} />} />
<Route path={`/${locale}/settings/feedback`} element={<FeedbackPage locale={locale} feedback={translations.feedback} />} />
<Route path={`/${locale}/divination/manual`} element={<ManualDivinationPage locale={locale} dashboard={dashboard} divination={translations.divination} />} />
<Route path={`/${locale}/divination/auto`} element={<AutoDivinationPage locale={locale} dashboard={dashboard} divination={translations.divination} />} />
<Route path={`/${locale}/divination/result`} element={<DivinationResultPage locale={locale} translations={translations.result} />} />
<Route path="*" element={<Navigate to={`/${locale}/dashboard`} replace />} />
</Routes>
<Suspense fallback={<RouteFallback />}>
<Routes>
<Route path={`/${locale}/dashboard`} element={<Dashboard locale={locale} translations={dashboard} />} />
<Route path={`/${locale}/store`} element={<StorePage locale={locale} dashboard={dashboard} store={translations.store} pricing={translations.pricing} />} />
<Route path={`/${locale}/history`} element={<HistoryListPage locale={locale} dashboard={dashboard} history={translations.history} />} />
<Route path={`/${locale}/history/:id`} element={<DivinationResultPage locale={locale} translations={translations.result} />} />
<Route path={`/${locale}/history/:id/followup`} element={<HistoryFollowUpPage locale={locale} dashboard={dashboard} history={translations.history} />} />
<Route path={`/${locale}/history/result`} element={<DivinationResultPage locale={locale} translations={translations.result} />} />
<Route path={`/${locale}/history/followup`} element={<HistoryFollowUpPage locale={locale} dashboard={dashboard} history={translations.history} />} />
<Route path={`/${locale}/notifications`} element={<NotificationPage locale={locale} dashboard={dashboard} notifications={translations.notifications} />} />
<Route path={`/${locale}/profile`} element={<ProfileDetailPage locale={locale} dashboard={dashboard} profile={translations.profile} />} />
<Route path={`/${locale}/settings`} element={<SettingsPage locale={locale} dashboard={dashboard} settings={translations.settings} />} />
<Route path={`/${locale}/settings/general`} element={<GeneralSettingsPage locale={locale} general={translations.general} />} />
<Route path={`/${locale}/settings/feedback`} element={<FeedbackPage locale={locale} feedback={translations.feedback} />} />
<Route path={`/${locale}/divination/manual`} element={<ManualDivinationPage locale={locale} dashboard={dashboard} divination={translations.divination} />} />
<Route path={`/${locale}/divination/auto`} element={<AutoDivinationPage locale={locale} dashboard={dashboard} divination={translations.divination} />} />
<Route path={`/${locale}/divination/result`} element={<DivinationResultPage locale={locale} translations={translations.result} />} />
<Route path="*" element={<Navigate to={`/${locale}/dashboard`} replace />} />
</Routes>
</Suspense>
</AppShell>
);
}
+5 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { historyMessageToResultData, type DivinationResultData, type YaoType } from '../lib/api';
import { useHistoryThread } from '../lib/resources';
import Icon from './Icon';
@@ -339,7 +339,9 @@ const RESULT_STORAGE_KEY = 'divination_result_data';
export default function DivinationResultPage({ locale, translations: t }: Props) {
const location = useLocation();
const navigate = useNavigate();
const { id: threadId } = useParams<{ id: string }>();
const { id: routeThreadId } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const threadId = routeThreadId ?? searchParams.get('threadId') ?? undefined;
const threadState = useHistoryThread(threadId);
const [data, setData] = useState<DivinationResultData | null>(null);
const [loading, setLoading] = useState(true);
@@ -403,7 +405,7 @@ export default function DivinationResultPage({ locale, translations: t }: Props)
const handleFollowUp = () => {
const effectiveThreadId = data?.threadId || threadId;
if (effectiveThreadId) {
navigate(`/${locale}/history/${effectiveThreadId}/followup`, { state: { result: data } });
navigate(`/${locale}/history/followup?threadId=${encodeURIComponent(effectiveThreadId)}`, { state: { result: data } });
}
};
+1 -1
View File
@@ -2,7 +2,7 @@ import { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { authFetch } from '../lib/auth';
import { API_ROUTES } from '../lib/api-routes';
import { apiUrl, jsonHeaders } from '../lib/api-client';
import { apiUrl } from '../lib/api-client';
interface Props {
locale: string;
+5 -3
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
historyMessageToResultData,
enqueueFollowUpRun,
@@ -63,7 +63,9 @@ const CATEGORY_COLORS: Record<string, string> = {
export default function HistoryFollowUpPage({ locale, history: h }: Props) {
const location = useLocation();
const navigate = useNavigate();
const { id: threadId } = useParams<{ id: string }>();
const { id: routeThreadId } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const threadId = routeThreadId ?? searchParams.get('threadId') ?? undefined;
const [resultData, setResultData] = useState<DivinationResultData | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
@@ -373,7 +375,7 @@ export default function HistoryFollowUpPage({ locale, history: h }: Props) {
<div className="bg-white rounded-2xl p-4 border border-slate-200 flex flex-col gap-2.5">
<h4 className="text-slate-900 text-sm font-bold">{h.relatedActions}</h4>
<button
onClick={() => navigate(`/${locale}/history/${threadId}`)}
onClick={() => threadId && navigate(`/${locale}/history/result?threadId=${encodeURIComponent(threadId)}`)}
className="flex items-center gap-2 text-sm text-slate-600 hover:text-violet-600 transition-colors py-1"
>
<Icon name="auto_awesome" className="w-4 h-4" />
+3 -2
View File
@@ -1,7 +1,7 @@
import { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { mapHistoryMessagesToItems, type HistoryItem } from '../lib/api';
import { useHistoryList } from '../lib/resources';
import { primeHistoryThreadFromSnapshot, useHistoryList } from '../lib/resources';
import Icon from './Icon';
interface Props {
@@ -126,7 +126,8 @@ export default function HistoryListPage({ locale, history: i18n }: Props) {
// 点击卡片跳转
const handleItemClick = (item: HistoryItem) => {
setSelectedId(item.id);
navigate(`/${locale}/history/${item.threadId}`);
if (historyState.data) primeHistoryThreadFromSnapshot(item.threadId, historyState.data);
navigate(`/${locale}/history/result?threadId=${encodeURIComponent(item.threadId)}`);
};
// 返回首页
+6 -7
View File
@@ -1,6 +1,6 @@
import { useState, useCallback, useEffect } from 'react';
import { sendOtp, loginWithEmail, getAuth, refreshAccessToken, ApiError, localeToBackendLanguage, backendLanguageToLocale } from '../lib/auth';
import { getUserProfile } from '../lib/api';
import { getProfileResource } from '../lib/resources';
interface LoginFormProps {
locale: string;
@@ -58,7 +58,7 @@ export default function LoginForm({ locale, translations: i18n, privacyUrl, term
try {
await refreshAccessToken();
// Token valid, get profile language and redirect
const profile = await getUserProfile();
const profile = await getProfileResource();
const userLocale = backendLanguageToLocale(profile.settings?.preferences?.language || 'zh-CN');
window.location.href = `/${userLocale}/dashboard`;
} catch {
@@ -89,8 +89,7 @@ export default function LoginForm({ locale, translations: i18n, privacyUrl, term
}
}, [email, countdown, locale]);
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
const handleSubmit = useCallback(async () => {
if (!email || !code || !agreed) return;
setError('');
setLoading(true);
@@ -98,9 +97,9 @@ export default function LoginForm({ locale, translations: i18n, privacyUrl, term
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const language = localeToBackendLanguage(locale);
await loginWithEmail(email, code, language, timezone);
// Get profile language and redirect to correct locale
const profile = await getUserProfile();
const userLocale = backendLanguageToLocale(profile.settings?.preferences?.language || language);
// Fresh login just sent this language to the backend. Avoid fetching profile
// before a full-page navigation that would lose the in-memory resource cache.
const userLocale = backendLanguageToLocale(language);
window.location.href = `/${userLocale}/dashboard`;
} catch (err) {
setError(getErrorMessage(err, locale));
-2
View File
@@ -1,5 +1,3 @@
import type { ReactNode } from 'react';
export interface NavItem {
id: string;
icon: string;
+4 -4
View File
@@ -26,7 +26,7 @@ export interface Translations {
footer: { brandName: string; desc: string; col1Title: string; col1Link1: string; col1Link2: string; col2Title: string; col2Link1: string; col2Link2: string; col3Title: string; col3Link1: string; col3Link2: string };
features: { title: string; subtitle: string; tagline: string; c1Title: string; c1Desc: string; c2Title: string; c2Desc: string; c3Title: string; c3Desc: string; c4Title: string; c4Desc: string; c5Title: string; c5Desc: string; c6Title: string; c6Desc: string };
pricing: { title: string; subtitle: string; p1Name: string; p1Badge: string; p1Price: string; p1Credits: string; p1Desc: string; p2Name: string; p2Price: string; p2Credits: string; p2Desc: string; p2Detail: string; p3Name: string; p3Badge: string; p3Price: string; p3Credits: string; p3Desc: string; p4Name: string; p4Price: string; p4Credits: string; p4Desc: string; p4Detail: string; buyNow: string };
about: { title: string; subtitle: string; storyTitle: string; p1: string; p2: string; p3: string; companyInfo: string; company: string; emailLabel: string; email: string; devLabel: string; developer: string; warningTitle: string; warningBody: string; legalTitle: string };
about: { title: string; subtitle: string; storyTitle: string; p1: string; p2: string; p3: string; companyInfo: string; company: string; emailLabel: string; email: string; devLabel: string; developer: string; icpLabel: string; icp: string; warningTitle: string; warningBody: string; legalTitle: string };
login: { welcome: string; subtitle: string; emailLabel: string; emailPlaceholder: string; codeLabel: string; codePlaceholder: string; sendCode: string; submit: string; agreePrefix: string; privacy: string; agreeAnd: string; terms: string };
dashboard: { brandName: string; navHome: string; navStore: string; navDivination: string; navManual: string; navAuto: string; navHistory: string; navLanguage: string; navSettings: string; greeting: string; greetingSub: string; heroTitle: string; heroDesc: string; heroCta: string; historyTitle: string; historyViewAll: string; logout: string };
notifications: { title: string; loading: string; error: string; empty: string; markAllRead: string; markAllReadDone: string };
@@ -50,7 +50,7 @@ const translations: Record<Locale, Translations> = {
footer: { brandName: '觅爻签问', desc: '以古老智慧,解读今时困惑。让每一次签问,都成为与自己对话的机会。', col1Title: '产品', col1Link1: '功能介绍', col1Link2: '定价', col2Title: '支持', col2Link1: '帮助中心', col2Link2: '联系我们', col3Title: '法律', col3Link1: '隐私政策', col3Link2: '服务条款' },
features: { title: '功能特性', subtitle: '以古老智慧解读今时困惑,觅爻签问提供完整的易学体验', tagline: '从起卦到解读,每一步都精心设计', c1Title: '两种起卦方式', c1Desc: '手动起卦与自动起卦,灵活选择最适合你的方式。推荐使用手动起卦,卦象解读更准确。', c2Title: 'AI 解卦分析', c2Desc: '基于传统六爻卦象与周易哲学体系,结合AI智能分析,提供深度卦象解读与建议。', c3Title: '九类问题覆盖', c3Desc: '事业、情感、财富、运势、解梦、健康、学业、寻物等九大领域,全面覆盖日常生活所问。', c4Title: '追问互动', c4Desc: '每次解卦后可深入追问一次,针对卦象细节获取更多洞见,让解读更加全面深入。', c5Title: '历史记录', c5Desc: '自动保存所有解卦记录,包括卦象详情与AI解读。随时回顾历史,追踪问题变化趋势。', c6Title: '点数系统', c6Desc: '提供灵活积分套餐:新人专享、入门补充、常用加量、高频进阶。按需购买,自由使用。' },
pricing: { title: '选择适合你的套餐', subtitle: '灵活积分套餐,按需选择,随时可用', p1Name: '新人专享包', p1Badge: '限购一次', p1Price: '$0.99', p1Credits: '60 积分', p1Desc: '最适合初次体验', p2Name: '入门补充包', p2Price: '$4.99', p2Credits: '100 积分', p2Desc: '日常解卦补充', p2Detail: '适量点数补充,经济实惠之选', p3Name: '常用加量包', p3Badge: '推荐', p3Price: '$7.99', p3Credits: '210 积分', p3Desc: '最适合日常使用', p4Name: '高频进阶包', p4Price: '$12.99', p4Credits: '415 积分', p4Desc: '重度使用优选', p4Detail: '大量点数储备,超值单价', buyNow: '立即购买' },
about: { title: '关于觅爻签问', subtitle: '了解我们的理念与定位', storyTitle: '我们的故事', p1: '觅爻签问是一个借助于 AI 解读传统六爻卦象的平台,为用户了解中国传统易学文化提供一个窗口。六爻卦象源于《周易》深邃的哲学体系,是古人探索世界运行规律的一种独特方法。古人认为宇宙万物相互关联,在你起卦时,你的心念与时空信息会凝结成卦象的方式呈现出来。', p2: '觅爻签问基于这样的思路,帮助你跳出局限思维,从全局和演变趋势看清矛盾、机会与风险,为判断和行动提供参考。', p3: '用 AI 解锁古老智慧,让觅爻签问成为你探索趋势、明晰方向的现代助手。', companyInfo: '公司信息', company: '洵觅科技(深圳)有限公司', emailLabel: '联系邮箱', email: 'xuyunlong@xunmee.com', devLabel: '开发者', developer: 'Ann Lee', warningTitle: '特别提醒', warningBody: '卦象解读结果均由 AI 生成,仅供娱乐参考,切不可作为商业、医疗等专业领域的决策依据。理性看待卦象,自由掌握人生。', legalTitle: '法律条款' },
about: { title: '关于觅爻签问', subtitle: '了解我们的理念与定位', storyTitle: '我们的故事', p1: '觅爻签问是一个借助于 AI 解读传统六爻卦象的平台,为用户了解中国传统易学文化提供一个窗口。六爻卦象源于《周易》深邃的哲学体系,是古人探索世界运行规律的一种独特方法。古人认为宇宙万物相互关联,在你起卦时,你的心念与时空信息会凝结成卦象的方式呈现出来。', p2: '觅爻签问基于这样的思路,帮助你跳出局限思维,从全局和演变趋势看清矛盾、机会与风险,为判断和行动提供参考。', p3: '用 AI 解锁古老智慧,让觅爻签问成为你探索趋势、明晰方向的现代助手。', companyInfo: '公司信息', company: '洵觅科技(深圳)有限公司', emailLabel: '联系邮箱', email: 'xuyunlong@xunmee.com', devLabel: '开发者', developer: 'Ann Lee', icpLabel: '备案号', icp: '粤ICP备2025428416号-1A', warningTitle: '特别提醒', warningBody: '卦象解读结果均由 AI 生成,仅供娱乐参考,切不可作为商业、医疗等专业领域的决策依据。理性看待卦象,自由掌握人生。', legalTitle: '法律条款' },
login: { welcome: '欢迎', subtitle: '使用邮箱验证码快速登录或注册', emailLabel: '邮箱地址', emailPlaceholder: '请输入邮箱地址', codeLabel: '验证码', codePlaceholder: '请输入验证码', sendCode: '获取验证码', submit: '登录 / 注册', agreePrefix: '我已阅读并同意', privacy: '《隐私政策》', agreeAnd: '和', terms: '《服务条款》' },
dashboard: { brandName: '觅爻签问', navHome: '首页', navStore: '商店', navDivination: '起卦', navManual: '手动起卦', navAuto: '自动起卦', navHistory: '历史解卦', navLanguage: '语言', navSettings: '设置', greeting: '下午好', greetingSub: '今天想要探寻什么方向?', heroTitle: '开始您的卦象之旅', heroDesc: '借助AI智能,探索未来的可能。心中有问,起卦便知。', heroCta: '立即起卦', historyTitle: '历史解卦', historyViewAll: '查看全部 →', logout: '退出登录' },
notifications: { title: '通知中心', loading: '加载中...', error: '加载失败', empty: '暂无通知', markAllRead: '全部已读', markAllReadDone: '已全部标记为已读' },
@@ -72,7 +72,7 @@ const translations: Record<Locale, Translations> = {
footer: { brandName: '覓爻簽問', desc: '以古老智慧,解讀今時困惑。讓每一次簽問,都成為與自己對話的機會。', col1Title: '產品', col1Link1: '功能介紹', col1Link2: '定價', col2Title: '支持', col2Link1: '幫助中心', col2Link2: '聯繫我們', col3Title: '法律', col3Link1: '隱私政策', col3Link2: '服務條款' },
features: { title: '功能特性', subtitle: '以古老智慧解讀今時困惑,覓爻簽問提供完整的易學體驗', tagline: '從起卦到解讀,每一步都精心設計', c1Title: '兩種起卦方式', c1Desc: '手動起卦與自動起卦,靈活選擇最適合你的方式。推薦使用手動起卦,卦象解讀更準確。', c2Title: 'AI 解卦分析', c2Desc: '基於傳統六爻卦象與周易哲學體系,結合AI智能分析,提供深度卦象解讀與建議。', c3Title: '九類問題覆蓋', c3Desc: '事業、情感、財富、運勢、解夢、健康、學業、尋物等九大領域,全面覆蓋日常生活所問。', c4Title: '追問互動', c4Desc: '每次解卦後可深入追問一次,針對卦象細節獲取更多洞見,讓解讀更加全面深入。', c5Title: '歷史記錄', c5Desc: '自動保存所有解卦記錄,包括卦象詳情與AI解讀。隨時回顧歷史,追蹤問題變化趨勢。', c6Title: '點數系統', c6Desc: '提供靈活積分套餐:新人專享、入門補充、常用加量、高頻進階。按需購買,自由使用。' },
pricing: { title: '選擇適合你的套餐', subtitle: '靈活積分套餐,按需選擇,隨時可用', p1Name: '新人專享包', p1Badge: '限購一次', p1Price: '$0.99', p1Credits: '60 積分', p1Desc: '最適合初次體驗', p2Name: '入門補充包', p2Price: '$4.99', p2Credits: '100 積分', p2Desc: '日常解卦補充', p2Detail: '適量點數補充,經濟實惠之選', p3Name: '常用加量包', p3Badge: '推薦', p3Price: '$7.99', p3Credits: '210 積分', p3Desc: '最適合日常使用', p4Name: '高頻進階包', p4Price: '$12.99', p4Credits: '415 積分', p4Desc: '重度使用優選', p4Detail: '大量點數儲備,超值單價', buyNow: '立即購買' },
about: { title: '關於覓爻簽問', subtitle: '了解我們的理念與定位', storyTitle: '我們的故事', p1: '覓爻簽問是一個借助於 AI 解讀傳統六爻卦象的平台,為用戶了解中國傳統易學文化提供一個窗口。六爻卦象源於《周易》深邃的哲學體系,是古人探索世界運行規律的一種獨特方法。古人認為宇宙萬物相互關聯,在你起卦時,你的心念與時空信息會凝結成卦象的方式呈現出來。', p2: '覓爻簽問基於這樣的思路,幫助你跳出局限思維,從全局和演變趨勢看清矛盾、機會與風險,為判斷和行動提供參考。', p3: '用 AI 解鎖古老智慧,讓覓爻簽問成為你探索趨勢、明晰方向的現代助手。', companyInfo: '公司信息', company: '洵覓科技(深圳)有限公司', emailLabel: '聯繫郵箱', email: 'xuyunlong@xunmee.com', devLabel: '開發者', developer: 'Ann Lee', warningTitle: '特別提醒', warningBody: '卦象解讀結果均由 AI 生成,僅供娛樂參考,切不可作為商業、醫療等專業領域的決策依據。理性看待卦象,自由掌握人生。', legalTitle: '法律條款' },
about: { title: '關於覓爻簽問', subtitle: '了解我們的理念與定位', storyTitle: '我們的故事', p1: '覓爻簽問是一個借助於 AI 解讀傳統六爻卦象的平台,為用戶了解中國傳統易學文化提供一個窗口。六爻卦象源於《周易》深邃的哲學體系,是古人探索世界運行規律的一種獨特方法。古人認為宇宙萬物相互關聯,在你起卦時,你的心念與時空信息會凝結成卦象的方式呈現出來。', p2: '覓爻簽問基於這樣的思路,幫助你跳出局限思維,從全局和演變趨勢看清矛盾、機會與風險,為判斷和行動提供參考。', p3: '用 AI 解鎖古老智慧,讓覓爻簽問成為你探索趨勢、明晰方向的現代助手。', companyInfo: '公司信息', company: '洵覓科技(深圳)有限公司', emailLabel: '聯繫郵箱', email: 'xuyunlong@xunmee.com', devLabel: '開發者', developer: 'Ann Lee', icpLabel: '備案號', icp: '粵ICP備2025428416號-1A', warningTitle: '特別提醒', warningBody: '卦象解讀結果均由 AI 生成,僅供娛樂參考,切不可作為商業、醫療等專業領域的決策依據。理性看待卦象,自由掌握人生。', legalTitle: '法律條款' },
login: { welcome: '歡迎', subtitle: '使用郵箱驗證碼快速登錄或註冊', emailLabel: '郵箱地址', emailPlaceholder: '請輸入郵箱地址', codeLabel: '驗證碼', codePlaceholder: '請輸入驗證碼', sendCode: '獲取驗證碼', submit: '登錄 / 註冊', agreePrefix: '我已閱讀並同意', privacy: '《隱私政策》', agreeAnd: '和', terms: '《服務條款》' },
dashboard: { brandName: '覓爻簽問', navHome: '首頁', navStore: '商店', navDivination: '起卦', navManual: '手動起卦', navAuto: '自動起卦', navHistory: '歷史解卦', navLanguage: '語言', navSettings: '設置', greeting: '下午好', greetingSub: '今天想要探尋什麼方向?', heroTitle: '開始您的卦象之旅', heroDesc: '借助AI智能,探索未來的可能。心中有問,起卦便知。', heroCta: '立即起卦', historyTitle: '歷史解卦', historyViewAll: '查看全部 →', logout: '退出登錄' },
notifications: { title: '通知中心', loading: '加載中...', error: '加載失敗', empty: '暫無通知', markAllRead: '全部已讀', markAllReadDone: '已全部標記為已讀' },
@@ -94,7 +94,7 @@ const translations: Record<Locale, Translations> = {
footer: { brandName: 'MeiYao Divination', desc: 'Using ancient wisdom to interpret modern confusion. Let every divination become a chance to dialogue with yourself.', col1Title: 'Product', col1Link1: 'Features', col1Link2: 'Pricing', col2Title: 'Support', col2Link1: 'Help Center', col2Link2: 'Contact Us', col3Title: 'Legal', col3Link1: 'Privacy Policy', col3Link2: 'Terms of Service' },
features: { title: 'Features', subtitle: 'Ancient wisdom meets modern困惑, MeiYao provides a complete I Ching experience', tagline: 'From casting to interpretation, every step is carefully designed', c1Title: 'Two Casting Methods', c1Desc: 'Manual and auto casting — choose what suits you best. Manual casting is recommended for more accurate readings.', c2Title: 'AI Analysis', c2Desc: 'Combining traditional Six-Line hexagrams with AI intelligence for in-depth interpretation and suggestions.', c3Title: '9 Question Categories', c3Desc: 'Career, love, wealth, fortune, dreams, health, study, lost items, and more — covering all aspects of daily life.', c4Title: 'Follow-up Questions', c4Desc: 'Ask one follow-up question after each reading for deeper insights into specific hexagram details.', c5Title: 'History', c5Desc: 'All readings are automatically saved with full hexagram details and AI interpretations. Review anytime.', c6Title: 'Credits System', c6Desc: 'Flexible credit packages: starter, basic, popular, and premium. Purchase as needed, use freely.' },
pricing: { title: 'Choose Your Plan', subtitle: 'Flexible credit packages, pay as you go', p1Name: 'Starter Pack', p1Badge: 'Once Only', p1Price: '$0.99', p1Credits: '60 credits', p1Desc: 'Best for first-timers', p2Name: 'Basic Pack', p2Price: '$4.99', p2Credits: '100 credits', p2Desc: 'Daily supplement', p2Detail: 'Affordable credit refill', p3Name: 'Popular Pack', p3Badge: 'Popular', p3Price: '$7.99', p3Credits: '210 credits', p3Desc: 'Best for daily use', p4Name: 'Premium Pack', p4Price: '$12.99', p4Credits: '415 credits', p4Desc: 'Best value per credit', p4Detail: 'Bulk credits at best unit price', buyNow: 'Buy Now' },
about: { title: 'About MeiYao Divination', subtitle: 'Our vision and philosophy', storyTitle: 'Our Story', p1: 'MeiYao Divination is an AI-assisted platform for interpreting traditional Six-Line divination and opening a window into classical Chinese wisdom. Six-Line divination originates from the deep philosophical system of the I Ching.', p2: 'MeiYao Divination helps you step outside narrow thinking, understand contradictions, opportunities, and risks from a broader trend perspective, and make calmer, more thoughtful decisions.', p3: 'Unlock ancient wisdom with AI. Let MeiYao Divination be your modern companion for exploring trends and finding direction.', companyInfo: 'Company', company: 'Xunmee Technology (Shenzhen) Co., Ltd.', emailLabel: 'Email', email: 'xuyunlong@xunmee.com', devLabel: 'Developer', developer: 'Ann Lee', warningTitle: 'Important Notice', warningBody: 'All divination interpretations are AI-generated for entertainment only. Do not use them as professional advice for business, medical, or legal decisions.', legalTitle: 'Legal' },
about: { title: 'About MeiYao Divination', subtitle: 'Our vision and philosophy', storyTitle: 'Our Story', p1: 'MeiYao Divination is an AI-assisted platform for interpreting traditional Six-Line divination and opening a window into classical Chinese wisdom. Six-Line divination originates from the deep philosophical system of the I Ching.', p2: 'MeiYao Divination helps you step outside narrow thinking, understand contradictions, opportunities, and risks from a broader trend perspective, and make calmer, more thoughtful decisions.', p3: 'Unlock ancient wisdom with AI. Let MeiYao Divination be your modern companion for exploring trends and finding direction.', companyInfo: 'Company', company: 'Xunmee Technology (Shenzhen) Co., Ltd.', emailLabel: 'Email', email: 'xuyunlong@xunmee.com', devLabel: 'Developer', developer: 'Ann Lee', icpLabel: 'ICP filing', icp: '粤ICP备2025428416号-1A', warningTitle: 'Important Notice', warningBody: 'All divination interpretations are AI-generated for entertainment only. Do not use them as professional advice for business, medical, or legal decisions.', legalTitle: 'Legal' },
login: { welcome: 'Welcome', subtitle: 'Sign in or sign up with email verification code', emailLabel: 'Email', emailPlaceholder: 'Enter your email', codeLabel: 'Verification Code', codePlaceholder: 'Enter code', sendCode: 'Send Code', submit: 'Sign In / Sign Up', agreePrefix: 'I have read and agree to the ', privacy: 'Privacy Policy', agreeAnd: ' and ', terms: 'Terms of Service' },
dashboard: { brandName: 'MeiYao Divination', navHome: 'Home', navStore: 'Store', navDivination: 'Divination', navManual: 'Manual Cast', navAuto: 'Auto Cast', navHistory: 'History', navLanguage: 'Language', navSettings: 'Settings', greeting: 'Good afternoon', greetingSub: 'What would you like to explore today?', heroTitle: 'Begin Your Hexagram Journey', heroDesc: 'Explore future possibilities with AI. Ask your question, cast your hexagram.', heroCta: 'Start Divination', historyTitle: 'Recent Readings', historyViewAll: 'View All →', logout: 'Sign Out' },
notifications: { title: 'Notifications', loading: 'Loading...', error: 'Failed to load', empty: 'No notifications', markAllRead: 'Mark All Read', markAllReadDone: 'All marked as read' },
+1 -4
View File
@@ -1,7 +1,6 @@
---
import '../styles/global.css';
import '../styles/animations.css';
import { AuthProvider } from '../components/AuthProvider';
interface Props {
locale: import('../i18n/utils').Locale;
@@ -20,9 +19,7 @@ const { locale } = Astro.props;
</head>
<body class="bg-slate-50 text-slate-900 antialiased">
<main>
<AuthProvider client:load>
<slot />
</AuthProvider>
<slot />
</main>
</body>
</html>
+1
View File
@@ -144,6 +144,7 @@ export async function loginWithEmail(
body: JSON.stringify(body),
});
const data = toAuthData(json);
clearDataCache();
setAuth(data);
return data;
}
+12 -1
View File
@@ -31,7 +31,6 @@ import {
query,
set,
subscribe,
type CacheKey,
type QueryOptions,
} from './data-client';
@@ -274,6 +273,18 @@ export function getHistoryThreadResource(threadId: string, force = false): Promi
});
}
export function primeHistoryThreadFromSnapshot(threadId: string, snapshot: HistorySnapshot): void {
const messages = snapshot.messages.filter((message) => message.threadId === threadId);
if (messages.length === 0) return;
set(historyThreadKey(threadId), {
scope: 'thread',
threadId,
day: snapshot.day,
hasMore: false,
messages,
}, HISTORY_THREAD_TTL);
}
export function useHistoryThread(threadId?: string): ResourceState<HistorySnapshot> {
return useResource({
key: threadId ? historyThreadKey(threadId) : ['history', 'thread', 'missing'],
-1
View File
@@ -4,7 +4,6 @@ export const prerender = false;
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'en' as const;
const { id } = Astro.params;
---
<DashboardAppPage locale={locale} />
@@ -4,7 +4,6 @@ export const prerender = false;
import DashboardAppPage from '../../../../components/DashboardAppPage.astro';
const locale = 'en' as const;
const { id } = Astro.params;
---
<DashboardAppPage locale={locale} />
+2 -18
View File
@@ -1,23 +1,7 @@
---
import App from '../../../layouts/App.astro';
import DashboardApp from '../../../components/DashboardApp';
import { t } from '../../../i18n/utils';
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'en' as const;
const translations = {
dashboard: t(locale, 'dashboard'),
store: t(locale, 'store'),
pricing: t(locale, 'pricing'),
history: t(locale, 'history'),
notifications: t(locale, 'notifications'),
profile: t(locale, 'profile'),
settings: t(locale, 'settings'),
divination: t(locale, 'divination'),
general: t(locale, 'general'),
feedback: t(locale, 'feedback'),
};
---
<App locale={locale}>
<DashboardApp client:only="react" locale={locale} translations={translations} />
</App>
<DashboardAppPage locale={locale} />
+2 -18
View File
@@ -1,23 +1,7 @@
---
import App from '../../../layouts/App.astro';
import DashboardApp from '../../../components/DashboardApp';
import { t } from '../../../i18n/utils';
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'en' as const;
const translations = {
dashboard: t(locale, 'dashboard'),
store: t(locale, 'store'),
pricing: t(locale, 'pricing'),
history: t(locale, 'history'),
notifications: t(locale, 'notifications'),
profile: t(locale, 'profile'),
settings: t(locale, 'settings'),
divination: t(locale, 'divination'),
general: t(locale, 'general'),
feedback: t(locale, 'feedback'),
};
---
<App locale={locale}>
<DashboardApp client:only="react" locale={locale} translations={translations} />
</App>
<DashboardAppPage locale={locale} />
-1
View File
@@ -4,7 +4,6 @@ export const prerender = false;
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'zh' as const;
const { id } = Astro.params;
---
<DashboardAppPage locale={locale} />
@@ -4,7 +4,6 @@ export const prerender = false;
import DashboardAppPage from '../../../../components/DashboardAppPage.astro';
const locale = 'zh' as const;
const { id } = Astro.params;
---
<DashboardAppPage locale={locale} />
+2 -18
View File
@@ -1,23 +1,7 @@
---
import App from '../../../layouts/App.astro';
import DashboardApp from '../../../components/DashboardApp';
import { t } from '../../../i18n/utils';
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'zh' as const;
const translations = {
dashboard: t(locale, 'dashboard'),
store: t(locale, 'store'),
pricing: t(locale, 'pricing'),
history: t(locale, 'history'),
notifications: t(locale, 'notifications'),
profile: t(locale, 'profile'),
settings: t(locale, 'settings'),
divination: t(locale, 'divination'),
general: t(locale, 'general'),
feedback: t(locale, 'feedback'),
};
---
<App locale={locale}>
<DashboardApp client:only="react" locale={locale} translations={translations} />
</App>
<DashboardAppPage locale={locale} />
+2 -18
View File
@@ -1,23 +1,7 @@
---
import App from '../../../layouts/App.astro';
import DashboardApp from '../../../components/DashboardApp';
import { t } from '../../../i18n/utils';
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'zh' as const;
const translations = {
dashboard: t(locale, 'dashboard'),
store: t(locale, 'store'),
pricing: t(locale, 'pricing'),
history: t(locale, 'history'),
notifications: t(locale, 'notifications'),
profile: t(locale, 'profile'),
settings: t(locale, 'settings'),
divination: t(locale, 'divination'),
general: t(locale, 'general'),
feedback: t(locale, 'feedback'),
};
---
<App locale={locale}>
<DashboardApp client:only="react" locale={locale} translations={translations} />
</App>
<DashboardAppPage locale={locale} />
-1
View File
@@ -4,7 +4,6 @@ export const prerender = false;
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'zh_Hant' as const;
const { id } = Astro.params;
---
<DashboardAppPage locale={locale} />
@@ -4,7 +4,6 @@ export const prerender = false;
import DashboardAppPage from '../../../../components/DashboardAppPage.astro';
const locale = 'zh_Hant' as const;
const { id } = Astro.params;
---
<DashboardAppPage locale={locale} />
+2 -18
View File
@@ -1,23 +1,7 @@
---
import App from '../../../layouts/App.astro';
import DashboardApp from '../../../components/DashboardApp';
import { t } from '../../../i18n/utils';
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'zh_Hant' as const;
const translations = {
dashboard: t(locale, 'dashboard'),
store: t(locale, 'store'),
pricing: t(locale, 'pricing'),
history: t(locale, 'history'),
notifications: t(locale, 'notifications'),
profile: t(locale, 'profile'),
settings: t(locale, 'settings'),
divination: t(locale, 'divination'),
general: t(locale, 'general'),
feedback: t(locale, 'feedback'),
};
---
<App locale={locale}>
<DashboardApp client:only="react" locale={locale} translations={translations} />
</App>
<DashboardAppPage locale={locale} />
+2 -18
View File
@@ -1,23 +1,7 @@
---
import App from '../../../layouts/App.astro';
import DashboardApp from '../../../components/DashboardApp';
import { t } from '../../../i18n/utils';
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
const locale = 'zh_Hant' as const;
const translations = {
dashboard: t(locale, 'dashboard'),
store: t(locale, 'store'),
pricing: t(locale, 'pricing'),
history: t(locale, 'history'),
notifications: t(locale, 'notifications'),
profile: t(locale, 'profile'),
settings: t(locale, 'settings'),
divination: t(locale, 'divination'),
general: t(locale, 'general'),
feedback: t(locale, 'feedback'),
};
---
<App locale={locale}>
<DashboardApp client:only="react" locale={locale} translations={translations} />
</App>
<DashboardAppPage locale={locale} />