Compare commits

...

2 Commits

Author SHA1 Message Date
Innei 59388cb06d feat(vite): add mobile index HTML rewrite plugin to shared renderer config
Serve index.mobile.html instead of index.html in Vite dev mode when platform is mobile.
2026-03-12 16:45:54 +08:00
Innei be7876931f feat(mobile): add mobile SPA dev workflow and subscription settings entries
Add mobile mode support to devStartupSequence (MOBILE env detection, dynamic SPA script/port selection). Add subscription-related settings entries (Plans, Funds, Usage, Billing, Referral) to mobile settings category when business features are enabled.
2026-03-12 16:29:33 +08:00
4 changed files with 156 additions and 2 deletions
+13
View File
@@ -1,5 +1,6 @@
import react from '@vitejs/plugin-react';
import { codeInspectorPlugin } from 'code-inspector-plugin';
import type { ViteDevServer } from 'vite';
import { nodePolyfills } from 'vite-plugin-node-polyfills';
import tsconfigPaths from 'vite-tsconfig-paths';
@@ -127,6 +128,18 @@ export function sharedRendererPlugins(options: SharedRendererOptions) {
viteNodeModuleStub(),
vitePlatformResolve(options.platform),
defaultTsconfigPaths && tsconfigPaths({ projects: ['.'] }),
isDev &&
options.platform === 'mobile' && {
name: 'vite-mobile-index',
configureServer(server: ViteDevServer) {
server.middlewares.use((req: { url?: string }, _res: unknown, next: () => void) => {
if (req.url === '/' || req.url === '/index.html') {
req.url = '/index.mobile.html';
}
next();
});
},
},
isDev &&
codeInspectorPlugin({
bundler: 'vite',
+28 -1
View File
@@ -23,6 +23,24 @@ const resolveNextPort = (): number => {
return 3010;
};
/**
* Parse the Vite dev port from the given spa script (e.g. dev:spa or dev:spa:mobile).
* Supports both `--port <n>` and `-p <n>` flags. Falls back to 9876.
*/
const resolveVitePort = (scriptName: string): number => {
try {
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf-8'));
const script: string | undefined = pkg?.scripts?.[scriptName];
if (script) {
const match = script.match(/(?:--port|-p)\s+(\d+)/);
if (match) return Number(match[1]);
}
} catch {
/* fallback */
}
return 9876;
};
const NEXT_PORT = resolveNextPort();
const NEXT_ROOT_URL = `http://${NEXT_HOST}:${NEXT_PORT}/`;
const NEXT_READY_TIMEOUT_MS = 180_000;
@@ -116,14 +134,23 @@ const watchChildExit = (child: ChildProcess, name: 'next' | 'vite') => {
});
};
const isMobile =
process.env.MOBILE === 'true' ||
process.env.MOBILE === '1' ||
process.argv.includes('MOBILE');
const main = async () => {
process.once('SIGINT', () => shutdownAll('SIGINT'));
process.once('SIGTERM', () => shutdownAll('SIGTERM'));
const spaScript = isMobile ? 'dev:spa:mobile' : 'dev:spa';
process.env.VITE_DEV_PORT = String(resolveVitePort(spaScript));
nextProcess = runNpmScript('dev:next');
watchChildExit(nextProcess, 'next');
viteProcess = runNpmScript('dev:spa');
if (isMobile) console.log('📱 Starting Vite in MOBILE mode');
viteProcess = runNpmScript(spaScript);
watchChildExit(viteProcess, 'vite');
runNextBackgroundTasks();
@@ -0,0 +1,64 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { SettingsTabs } from '@/store/global/initialState';
import { ServerConfigStoreProvider } from '@/store/serverConfig/Provider';
import { useCategory } from '../features/useCategory';
const wrapperWithBusinessEnabled: React.JSXElementConstructor<{ children: React.ReactNode }> = ({
children,
}) => (
<ServerConfigStoreProvider serverConfig={{ enableBusinessFeatures: true } as any}>
{children}
</ServerConfigStoreProvider>
);
const mockNavigate = vi.fn();
vi.mock('react-router-dom', () => ({
useNavigate: () => mockNavigate,
}));
vi.mock('react-i18next', () => ({
useTranslation: vi.fn(() => ({
t: vi.fn((key) => key),
})),
}));
afterEach(() => {
mockNavigate.mockReset();
});
describe('mobile me settings useCategory', () => {
it('should expose subscription entries when business features are enabled', () => {
const { result } = renderHook(() => useCategory(), {
wrapper: wrapperWithBusinessEnabled,
});
const keys = result.current.map((item) => item.key);
expect(keys).toContain(SettingsTabs.Plans);
expect(keys).toContain(SettingsTabs.Funds);
expect(keys).toContain(SettingsTabs.Usage);
expect(keys).toContain(SettingsTabs.Billing);
expect(keys).toContain(SettingsTabs.Referral);
});
it('should navigate subscription entries to mobile subscription routes', () => {
const { result } = renderHook(() => useCategory(), {
wrapper: wrapperWithBusinessEnabled,
});
const billingItem = result.current.find((item) => item.key === SettingsTabs.Billing);
const fundsItem = result.current.find((item) => item.key === SettingsTabs.Funds);
act(() => {
billingItem?.onClick?.();
fundsItem?.onClick?.();
});
expect(mockNavigate).toHaveBeenNthCalledWith(1, '/subscription/billing');
expect(mockNavigate).toHaveBeenNthCalledWith(2, '/subscription/funds');
});
});
@@ -1,13 +1,28 @@
import { Brain, BrainCircuit, Info, Mic2, Settings2, Sparkles } from 'lucide-react';
import {
Brain,
BrainCircuit,
Coins,
CreditCard,
Gift,
Info,
Map,
Mic2,
PieChart,
Settings2,
Sparkles,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { type CellProps } from '@/components/Cell';
import { SettingsTabs } from '@/store/global/initialState';
import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig';
export const useCategory = () => {
const navigate = useNavigate();
const { t } = useTranslation('setting');
const { t: tSubscription } = useTranslation('subscription');
const enableBusinessFeatures = useServerConfigStore(serverConfigSelectors.enableBusinessFeatures);
const items: CellProps[] = [
{
@@ -30,6 +45,31 @@ export const useCategory = () => {
key: SettingsTabs.Memory,
label: t('tab.memory'),
},
enableBusinessFeatures && {
icon: Map,
key: SettingsTabs.Plans,
label: tSubscription('tab.plans'),
},
enableBusinessFeatures && {
icon: Coins,
key: SettingsTabs.Funds,
label: tSubscription('tab.funds'),
},
enableBusinessFeatures && {
icon: PieChart,
key: SettingsTabs.Usage,
label: tSubscription('tab.usage'),
},
enableBusinessFeatures && {
icon: CreditCard,
key: SettingsTabs.Billing,
label: tSubscription('tab.billing'),
},
enableBusinessFeatures && {
icon: Gift,
key: SettingsTabs.Referral,
label: tSubscription('tab.referral'),
},
{ icon: Mic2, key: SettingsTabs.TTS, label: t('tab.tts') },
{
icon: Info,
@@ -43,6 +83,16 @@ export const useCategory = () => {
onClick: () => {
if (item.key === SettingsTabs.Provider) {
navigate('/settings/provider/all');
} else if (
[
SettingsTabs.Plans,
SettingsTabs.Funds,
SettingsTabs.Usage,
SettingsTabs.Billing,
SettingsTabs.Referral,
].includes(item.key as SettingsTabs)
) {
navigate(`/subscription/${item.key}`);
} else {
navigate(`/settings/${item.key}`);
}