🐛 fix(desktop): add missing Stats and Creds tabs to Electron componentMap (#13243)

This commit is contained in:
Innei
2026-03-25 16:27:37 +08:00
committed by GitHub
parent 991de25b97
commit 04ddb992d1
15 changed files with 178 additions and 52 deletions

View File

@@ -37,6 +37,10 @@ description: 'Code review checklist for LobeHub. Use when reviewing PRs, diffs,
- Keys added to `src/locales/default/{namespace}.ts` with `{feature}.{context}.{action|status}` naming
- For PRs: `locales/` translations for all languages updated (`pnpm i18n`)
### SPA / routing
- **`desktopRouter` pair:** If the diff touches `src/spa/router/desktopRouter.config.tsx`, does it also update `src/spa/router/desktopRouter.config.desktop.tsx` with the same route paths and nesting? Single-file edits often cause drift and blank screens.
### Reuse
- Newly written code duplicates existing utilities in `packages/utils` or shared modules?

View File

@@ -32,15 +32,28 @@ Hybrid routing: Next.js App Router (static pages) + React Router DOM (main SPA).
| Route Type | Use Case | Implementation |
| ------------------ | --------------------------------- | ---------------------------- |
| Next.js App Router | Auth pages (login, signup, oauth) | `src/app/[variants]/(auth)/` |
| React Router DOM | Main SPA (chat, settings) | `desktopRouter.config.tsx` |
| React Router DOM | Main SPA (chat, settings) | `desktopRouter.config.tsx` + `desktopRouter.config.desktop.tsx` (must match) |
### Key Files
- Entry: `src/spa/entry.web.tsx` (web), `src/spa/entry.mobile.tsx`, `src/spa/entry.desktop.tsx`
- Desktop router: `src/spa/router/desktopRouter.config.tsx`
- Desktop router (pair — **always edit both** when changing routes): `src/spa/router/desktopRouter.config.tsx` (dynamic imports) and `src/spa/router/desktopRouter.config.desktop.tsx` (sync imports). Drift can cause unregistered routes / blank screen.
- Mobile router: `src/spa/router/mobileRouter.config.tsx`
- Router utilities: `src/utils/router.tsx`
### `.desktop.{ts,tsx}` File Sync Rule
**CRITICAL**: Some files have a `.desktop.ts(x)` variant that Electron uses instead of the base file. When editing a base file, **always check** if a `.desktop` counterpart exists and update it in sync. Drift causes blank pages or missing features in Electron.
Known pairs that must stay in sync:
| Base file (web, dynamic imports) | Desktop file (Electron, sync imports) |
| --- | --- |
| `src/spa/router/desktopRouter.config.tsx` | `src/spa/router/desktopRouter.config.desktop.tsx` |
| `src/routes/(main)/settings/features/componentMap.ts` | `src/routes/(main)/settings/features/componentMap.desktop.ts` |
**How to check**: After editing any `.ts` / `.tsx` file, run `Glob` for `<filename>.desktop.{ts,tsx}` in the same directory. If a match exists, update it with the equivalent sync-import change.
### Router Utilities
```tsx

View File

@@ -1,6 +1,6 @@
---
name: spa-routes
description: SPA route and feature structure. Use when adding or modifying SPA routes in src/routes, defining new route segments, or moving route logic into src/features. Covers how to keep routes thin and how to divide files between routes and features.
description: MUST use when editing src/routes/ segments, src/spa/router/desktopRouter.config.tsx or desktopRouter.config.desktop.tsx (always change both together), mobileRouter.config.tsx, or when moving UI/logic between routes and src/features/.
---
# SPA Routes and Features Guide
@@ -13,6 +13,8 @@ SPA structure:
This project uses a **roots vs features** split: `src/routes/` only holds page segments; business logic and UI live in `src/features/` by domain.
**Agent constraint — desktop router parity:** Edits to the desktop route tree must update **both** `src/spa/router/desktopRouter.config.tsx` and `src/spa/router/desktopRouter.config.desktop.tsx` in the same change (same paths, nesting, index routes, and segment registration). Updating only one causes drift; the missing tree can fail to register routes and surface as a **blank screen** or broken navigation on the affected build.
## When to Use This Skill
- Adding a new SPA route or route segment
@@ -73,8 +75,21 @@ Each feature should:
- Layout: `export { default } from '@/features/MyFeature/MyLayout'` or compose a few feature components + `<Outlet />`.
- Page: import from `@/features/MyFeature` (or a specific subpath) and render; no business logic in the route file.
5. **Register the route**
- Add the segment to `src/spa/router/desktopRouter.config.tsx` (or the right router config) with `dynamicElement` / `dynamicLayout` pointing at the new route paths (e.g. `@/routes/(main)/my-feature`).
5. **Register the route (desktop — two files, always)**
- **`desktopRouter.config.tsx`:** Add the segment with `dynamicElement` / `dynamicLayout` pointing at route modules (e.g. `@/routes/(main)/my-feature`).
- **`desktopRouter.config.desktop.tsx`:** Mirror the **same** `RouteObject` shape: identical `path` / `index` / parent-child structure. Use the static imports and elements already used in that file (see neighboring routes). Do **not** register in only one of these files.
- **Mobile-only flows:** use `mobileRouter.config.tsx` instead (no need to duplicate into the desktop pair unless the route truly exists on both).
---
## 3a. Desktop router pair (`desktopRouter.config` × 2)
| File | Role |
|------|------|
| `desktopRouter.config.tsx` | Dynamic imports via `dynamicElement` / `dynamicLayout` — code-splitting; used by `entry.web.tsx` and `entry.desktop.tsx`. |
| `desktopRouter.config.desktop.tsx` | Same route tree with **synchronous** imports — kept for Electron / local parity and predictable bundling. |
Anything that changes the tree (new segment, renamed `path`, moved layout, new child route) must be reflected in **both** files in one PR or commit. Remove routes from both when deleting.
---

View File

@@ -10,8 +10,8 @@ const Installation = memo<{ mobile?: boolean }>(({ mobile }) => {
return (
<Platform
downloadUrl={downloadUrl}
expandCodeByDefault
downloadUrl={downloadUrl}
identifier={identifier}
mobile={mobile}
/>

View File

@@ -22,6 +22,9 @@ const Versions = memo(() => {
<Title>{t('skills.details.versions.title')}</Title>
<Block variant={'outlined'}>
<InlineTable
dataSource={versions}
rowKey={'version'}
size={'middle'}
columns={[
{
dataIndex: 'version',
@@ -35,7 +38,7 @@ const Versions = memo(() => {
url: pathname,
})}
>
<Flexbox align={'center'} gap={8} horizontal>
<Flexbox horizontal align={'center'} gap={8}>
<code style={{ fontSize: 14 }}>{record.version}</code>
{record.isLatest && (
<Tag color={'info'}>{t('skills.details.versions.table.isLatest')}</Tag>
@@ -52,9 +55,6 @@ const Versions = memo(() => {
title: t('skills.details.versions.table.publishAt'),
},
]}
dataSource={versions}
rowKey={'version'}
size={'middle'}
/>
</Block>
</Flexbox>

View File

@@ -80,8 +80,8 @@ const Header = memo<{ mobile?: boolean }>(({ mobile }) => {
const resourcesCount = (Object.values(resources || {})?.length || 0) + 1;
const scores = (
<Flexbox align={'center'} className={styles.extraTag} gap={16} horizontal>
<Flexbox align={'center'} className={styles.extraTagActive} gap={8} horizontal>
<Flexbox horizontal align={'center'} className={styles.extraTag} gap={16}>
<Flexbox horizontal align={'center'} className={styles.extraTagActive} gap={8}>
<Icon icon={FileTextIcon} size={14} />
{resourcesCount}
</Flexbox>
@@ -103,7 +103,7 @@ const Header = memo<{ mobile?: boolean }>(({ mobile }) => {
return (
<Flexbox gap={12}>
<Flexbox align={'flex-start'} gap={16} horizontal width={'100%'}>
<Flexbox horizontal align={'flex-start'} gap={16} width={'100%'}>
<Avatar avatar={icon || name} size={mobile ? 48 : 64} />
<Flexbox
flex={1}
@@ -113,9 +113,9 @@ const Header = memo<{ mobile?: boolean }>(({ mobile }) => {
}}
>
<Flexbox
horizontal
align={'center'}
gap={8}
horizontal
justify={'space-between'}
style={{
overflow: 'hidden',
@@ -123,18 +123,18 @@ const Header = memo<{ mobile?: boolean }>(({ mobile }) => {
}}
>
<Flexbox
horizontal
align={'center'}
flex={1}
gap={12}
horizontal
style={{
overflow: 'hidden',
position: 'relative',
}}
>
<Text
as={'h1'}
ellipsis
as={'h1'}
style={{ fontSize: mobile ? 18 : 24, margin: 0 }}
title={identifier}
>
@@ -142,22 +142,22 @@ const Header = memo<{ mobile?: boolean }>(({ mobile }) => {
</Text>
{!mobile && scores}
</Flexbox>
<Flexbox align={'center'} gap={6} horizontal>
<Flexbox horizontal align={'center'} gap={6}>
{homepage && (
<a
href={homepage}
onClick={stopPropagation}
rel="noopener noreferrer"
target={'_blank'}
onClick={stopPropagation}
>
<ActionIcon fill={cssVar.colorTextDescription} icon={Github} />
</a>
)}
</Flexbox>
</Flexbox>
<Flexbox align={'center'} gap={4} horizontal>
<Flexbox horizontal align={'center'} gap={4}>
{Boolean(ratingAverage) ? (
<Flexbox align={'center'} gap={8} horizontal>
<Flexbox horizontal align={'center'} gap={8}>
<Icon fill={cssVar.colorWarning} icon={StarIcon} size={14} />
<Text weight={500}>{ratingAverage?.toFixed(1)}</Text>
</Flexbox>
@@ -182,37 +182,37 @@ const Header = memo<{ mobile?: boolean }>(({ mobile }) => {
</Flexbox>
</Flexbox>
<Flexbox
horizontal
align={'center'}
gap={mobile ? 12 : 24}
horizontal
wrap={'wrap'}
style={{
color: cssVar.colorTextSecondary,
}}
wrap={'wrap'}
>
{mobile && scores}
{!mobile && cateButton}
<Flexbox align={'center'} gap={mobile ? 12 : 24} horizontal wrap={'wrap'}>
<Flexbox horizontal align={'center'} gap={mobile ? 12 : 24} wrap={'wrap'}>
{Boolean(license?.name) && (
<Flexbox align={'center'} gap={6} horizontal>
<Flexbox horizontal align={'center'} gap={6}>
<Icon icon={ScaleIcon} size={14} />
{license?.name}
</Flexbox>
)}
{Boolean(installCount) && (
<Flexbox align={'center'} gap={6} horizontal>
<Flexbox horizontal align={'center'} gap={6}>
<Icon icon={DownloadIcon} size={14} />
{formatCompactNumber(installCount)}
</Flexbox>
)}
{Boolean(github?.stars) && (
<Flexbox align={'center'} gap={6} horizontal>
<Flexbox horizontal align={'center'} gap={6}>
<Icon icon={StarIcon} size={14} />
{formatCompactNumber(github?.stars)}
</Flexbox>
)}
{Boolean(comments?.totalCount) && (
<Flexbox align={'center'} gap={6} horizontal>
<Flexbox horizontal align={'center'} gap={6}>
<Icon icon={MessageSquare} size={14} />
{formatCompactNumber(comments?.totalCount)}
</Flexbox>

View File

@@ -27,7 +27,7 @@ const InstallationConfig = memo(() => {
<Title more={t('mcp.details.sidebar.moreServerConfig')} moreLink={installLink}>
{t('skills.details.sidebar.installationConfig')}
</Title>
<Platform downloadUrl={downloadUrl} expandCodeByDefault identifier={identifier} lite />
<Platform expandCodeByDefault lite downloadUrl={downloadUrl} identifier={identifier} />
</Flexbox>
);
});

View File

@@ -9,6 +9,7 @@ import About from '../about';
import Advanced from '../advanced';
import APIKey from '../apikey';
import Appearance from '../appearance';
import Creds from '../creds';
import Hotkey from '../hotkey';
import Memory from '../memory';
import Profile from '../profile';
@@ -17,6 +18,7 @@ import Proxy from '../proxy';
import Security from '../security';
import ServiceModel from '../service-model';
import Skill from '../skill';
import Stats from '../stats';
import Storage from '../storage';
import SystemTools from '../system-tools';
@@ -33,8 +35,10 @@ export const componentMap = {
[SettingsTabs.Storage]: Storage,
// Profile related tabs
[SettingsTabs.Profile]: Profile,
[SettingsTabs.Stats]: Stats,
[SettingsTabs.Usage]: Usage,
[SettingsTabs.APIKey]: APIKey,
[SettingsTabs.Creds]: Creds,
[SettingsTabs.Security]: Security,
[SettingsTabs.Skill]: Skill,

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { componentMap as webMap } from './componentMap';
import { componentMap as desktopMap } from './componentMap.desktop';
describe('componentMap desktop sync', () => {
it('desktop keys must match web keys', () => {
const webKeys = Object.keys(webMap).sort();
const desktopKeys = Object.keys(desktopMap).sort();
const missingInDesktop = webKeys.filter((k) => !desktopKeys.includes(k));
const extraInDesktop = desktopKeys.filter((k) => !webKeys.includes(k));
expect(
missingInDesktop,
`Missing in componentMap.desktop: ${missingInDesktop.join(', ')}`,
).toEqual([]);
expect(extraInDesktop, `Extra in componentMap.desktop: ${extraInDesktop.join(', ')}`).toEqual(
[],
);
});
});

View File

@@ -104,12 +104,12 @@ const UsernameRow = ({ mobile }: UsernameRowProps) => {
)}
{dirty && !saving && (
<Button
size="small"
variant="outlined"
onMouseDown={(e) => {
e.preventDefault();
handleCancel();
}}
size="small"
variant="outlined"
>
{t('profile.cancel')}
</Button>
@@ -125,13 +125,13 @@ const UsernameRow = ({ mobile }: UsernameRowProps) => {
variant="filled"
onBlur={handleSave}
onChange={handleChange}
onPressEnter={handleSave}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
handleCancel();
}
}}
onPressEnter={handleSave}
/>
</Flexbox>
</Flexbox>

View File

@@ -462,3 +462,10 @@ desktopRoutes.push({
errorElement: <ErrorBoundary resetPath="/" />,
path: '/desktop-onboarding',
});
// Web onboarding aliases redirect to the desktop-specific onboarding flow.
desktopRoutes.push({
element: redirectElement('/desktop-onboarding'),
errorElement: <ErrorBoundary resetPath="/" />,
path: '/onboarding',
});

View File

@@ -0,0 +1,51 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
/**
* Known path pairs that intentionally differ between web and desktop (Electron).
* Map: desktop path → web path
*/
const KNOWN_DIVERGENCES: Record<string, string> = {
'/desktop-onboarding': '/onboarding',
};
function extractIndexCount(source: string) {
return [...source.matchAll(/index:\s*true/g)].length;
}
function extractPaths(source: string) {
return [...source.matchAll(/path:\s*'([^']+)'/g)].map((match) => match[1]);
}
function normalizePaths(paths: string[]) {
return [...new Set(paths.map((path) => KNOWN_DIVERGENCES[path] ?? path))].sort();
}
describe('desktopRouter config sync', () => {
it('desktop (sync) route paths must match web (async) route paths', async () => {
const asyncSource = await readFile(
join(process.cwd(), 'src/spa/router/desktopRouter.config.tsx'),
'utf8',
);
const syncSource = await readFile(
join(process.cwd(), 'src/spa/router/desktopRouter.config.desktop.tsx'),
'utf8',
);
const asyncPaths = normalizePaths(extractPaths(asyncSource));
const syncPaths = normalizePaths(extractPaths(syncSource));
const missingInSync = asyncPaths.filter((p) => !syncPaths.includes(p));
const extraInSync = syncPaths.filter((p) => !asyncPaths.includes(p));
const asyncIndexCount = extractIndexCount(asyncSource);
const syncIndexCount = extractIndexCount(syncSource);
expect(missingInSync, `Missing in desktop config: ${missingInSync.join(', ')}`).toEqual([]);
expect(extraInSync, `Extra in desktop config: ${extraInSync.join(', ')}`).toEqual([]);
expect(syncIndexCount, 'Desktop config index route count must match async config').toBe(
asyncIndexCount,
);
});
});

View File

@@ -0,0 +1,3 @@
const emojiMartData = {};
export default emojiMartData;

View File

@@ -0,0 +1,3 @@
const EmojiMartPicker = () => null;
export default EmojiMartPicker;

View File

@@ -1,12 +1,32 @@
import { dirname, join, resolve } from 'node:path';
import tsconfigPaths from 'vite-tsconfig-paths';
import { coverageConfigDefaults, defineConfig } from 'vitest/config';
const alias = {
'@emoji-mart/data': resolve(__dirname, './tests/mocks/emojiMartData.ts'),
'@emoji-mart/react': resolve(__dirname, './tests/mocks/emojiMartReact.tsx'),
'@/database/_deprecated': resolve(__dirname, './src/database/_deprecated'),
'@/utils/client/switchLang': resolve(__dirname, './src/utils/client/switchLang'),
'@/const/locale': resolve(__dirname, './src/const/locale'),
// TODO: after refactor the errorResponse, we can remove it
'@/utils/errorResponse': resolve(__dirname, './src/utils/errorResponse'),
'@/utils/unzipFile': resolve(__dirname, './src/utils/unzipFile'),
'@/utils/server': resolve(__dirname, './src/utils/server'),
'@/utils/identifier': resolve(__dirname, './src/utils/identifier'),
'@/utils/electron': resolve(__dirname, './src/utils/electron'),
'@/utils/markdownToTxt': resolve(__dirname, './src/utils/markdownToTxt'),
'@/utils/sanitizeFileName': resolve(__dirname, './src/utils/sanitizeFileName'),
'~test-utils': resolve(__dirname, './tests/utils.tsx'),
'lru_map': resolve(__dirname, './tests/mocks/lru_map'),
};
export default defineConfig({
optimizeDeps: {
exclude: ['crypto', 'util', 'tty'],
include: ['@lobehub/tts'],
},
plugins: [
tsconfigPaths({ projects: ['.'] }),
/**
* @lobehub/fluent-emoji@4.0.0 ships `es/FluentEmoji/style.js` but its `es/FluentEmoji/index.js`
* imports `./style/index.js` which doesn't exist.
@@ -38,28 +58,11 @@ export default defineConfig({
},
},
],
resolve: {
alias,
},
test: {
alias: {
'@/database/_deprecated': resolve(__dirname, './src/database/_deprecated'),
'@/database': resolve(__dirname, './packages/database/src'),
'@/utils/client/switchLang': resolve(__dirname, './src/utils/client/switchLang'),
'@/const/locale': resolve(__dirname, './src/const/locale'),
// TODO: after refactor the errorResponse, we can remove it
'@/utils/errorResponse': resolve(__dirname, './src/utils/errorResponse'),
'@/utils/unzipFile': resolve(__dirname, './src/utils/unzipFile'),
'@/utils/server': resolve(__dirname, './src/utils/server'),
'@/utils/identifier': resolve(__dirname, './src/utils/identifier'),
'@/utils/electron': resolve(__dirname, './src/utils/electron'),
'@/utils/markdownToTxt': resolve(__dirname, './src/utils/markdownToTxt'),
'@/utils/sanitizeFileName': resolve(__dirname, './src/utils/sanitizeFileName'),
'@/utils': resolve(__dirname, './packages/utils/src'),
'@/types': resolve(__dirname, './packages/types/src'),
'@/const': resolve(__dirname, './packages/const/src'),
'@': resolve(__dirname, './src'),
'~test-utils': resolve(__dirname, './tests/utils.tsx'),
'lru_map': resolve(__dirname, './tests/mocks/lru_map'),
},
alias,
coverage: {
all: false,
exclude: [
@@ -99,6 +102,7 @@ export default defineConfig({
deps: {
inline: [
'vitest-canvas-mock',
/@emoji-mart/,
'@lobehub/ui',
'@lobehub/fluent-emoji',
'@pierre/diffs',