feat: add premium public landing page
This commit is contained in:
@@ -0,0 +1,205 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import App from './App.js';
|
||||||
|
|
||||||
|
const { apiMock, userApiMock, authSessionMock } = vi.hoisted(() => ({
|
||||||
|
apiMock: {
|
||||||
|
getEvents: vi.fn(),
|
||||||
|
},
|
||||||
|
userApiMock: {
|
||||||
|
getAnnouncements: vi.fn(),
|
||||||
|
},
|
||||||
|
authSessionMock: {
|
||||||
|
getSessionToken: vi.fn(),
|
||||||
|
clearSession: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('react-dom', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('react-dom')>('react-dom');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
createPortal: (node: unknown) => node,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('./api.js', () => ({
|
||||||
|
api: apiMock,
|
||||||
|
userApi: userApiMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./authSession.js', () => ({
|
||||||
|
getSessionToken: authSessionMock.getSessionToken,
|
||||||
|
clearSession: authSessionMock.clearSession,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./components/SearchModal.js', () => ({
|
||||||
|
default: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./components/NotificationPanel.js', () => ({
|
||||||
|
default: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./components/TooltipLayer.js', () => ({
|
||||||
|
default: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./components/useAnimatedVisibility.js', () => ({
|
||||||
|
useAnimatedVisibility: (open: boolean) => ({
|
||||||
|
shouldRender: open,
|
||||||
|
isVisible: open,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./i18n.js', () => ({
|
||||||
|
I18nProvider: ({ children }: { children: ReactNode }) => children,
|
||||||
|
useI18n: () => ({
|
||||||
|
language: 'zh',
|
||||||
|
toggleLanguage: vi.fn(),
|
||||||
|
t: (text: string) => text,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./pages/Dashboard.js', () => ({
|
||||||
|
default: () => <div>Dashboard</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./pages/Landing.js', () => ({
|
||||||
|
default: () => <div>Public Landing Route</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function collectText(node: ReactTestInstance): string {
|
||||||
|
return (node.children || []).map((child) => {
|
||||||
|
if (typeof child === 'string') return child;
|
||||||
|
return collectText(child);
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLocalStorage() {
|
||||||
|
const store = new Map<string, string>([
|
||||||
|
['metapi.theme.mode', 'dark'],
|
||||||
|
['metapi.firstUseDocReminder', '1'],
|
||||||
|
['metapi.userProfile', JSON.stringify({
|
||||||
|
name: '管理员',
|
||||||
|
avatarSeed: 'seed-1',
|
||||||
|
avatarStyle: 'identicon',
|
||||||
|
})],
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
getItem: (key: string) => store.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => {
|
||||||
|
store.set(key, String(value));
|
||||||
|
},
|
||||||
|
removeItem: (key: string) => {
|
||||||
|
store.delete(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupRuntime() {
|
||||||
|
const matchMedia = (query: string) => ({
|
||||||
|
matches: query.includes('prefers-color-scheme'),
|
||||||
|
media: query,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
onchange: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal('localStorage', createLocalStorage());
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
innerWidth: 1280,
|
||||||
|
matchMedia,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('document', {
|
||||||
|
body: { style: {} },
|
||||||
|
documentElement: {
|
||||||
|
setAttribute: vi.fn(),
|
||||||
|
getAttribute: vi.fn(),
|
||||||
|
},
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushMicrotasks() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('App public landing', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
setupRuntime();
|
||||||
|
apiMock.getEvents.mockResolvedValue([]);
|
||||||
|
userApiMock.getAnnouncements.mockResolvedValue({ items: [] });
|
||||||
|
authSessionMock.clearSession.mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the public landing route at / when no session exists', async () => {
|
||||||
|
authSessionMock.getSessionToken.mockReturnValue(null);
|
||||||
|
let root!: ReactTestRenderer;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/']}>
|
||||||
|
<App />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const text = collectText(root.root);
|
||||||
|
expect(text).toContain('Public Landing Route');
|
||||||
|
expect(text).not.toContain('用户登录后继续');
|
||||||
|
} finally {
|
||||||
|
if (root) {
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the admin dashboard at / after admin session exists', async () => {
|
||||||
|
authSessionMock.getSessionToken.mockImplementation((audience: string) => (
|
||||||
|
audience === 'admin' ? 'admin-token' : null
|
||||||
|
));
|
||||||
|
let root!: ReactTestRenderer;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/']}>
|
||||||
|
<App />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const text = collectText(root.root);
|
||||||
|
expect(text).toContain('Dashboard');
|
||||||
|
expect(text).not.toContain('Public Landing Route');
|
||||||
|
} finally {
|
||||||
|
if (root) {
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+10
-4
@@ -21,6 +21,7 @@ import CenteredModal from './components/CenteredModal.js';
|
|||||||
import AdminLoginPage from './pages/auth/AdminLoginPage.js';
|
import AdminLoginPage from './pages/auth/AdminLoginPage.js';
|
||||||
import UserLoginPage from './pages/auth/UserLoginPage.js';
|
import UserLoginPage from './pages/auth/UserLoginPage.js';
|
||||||
import { SiteAnnouncementContent } from './pages/helpers/siteAnnouncementPresentation.js';
|
import { SiteAnnouncementContent } from './pages/helpers/siteAnnouncementPresentation.js';
|
||||||
|
const Landing = lazy(() => import('./pages/Landing.js'));
|
||||||
const Dashboard = lazy(() => import('./pages/Dashboard.js'));
|
const Dashboard = lazy(() => import('./pages/Dashboard.js'));
|
||||||
const Sites = lazy(() => import('./pages/Sites.js'));
|
const Sites = lazy(() => import('./pages/Sites.js'));
|
||||||
const Accounts = lazy(() => import('./pages/Accounts.js'));
|
const Accounts = lazy(() => import('./pages/Accounts.js'));
|
||||||
@@ -697,8 +698,7 @@ function AppShell() {
|
|||||||
};
|
};
|
||||||
const isAdminLoginPage = location.pathname === '/boos';
|
const isAdminLoginPage = location.pathname === '/boos';
|
||||||
const isPublicUserPage = ['/user/login', '/user/register'].includes(location.pathname);
|
const isPublicUserPage = ['/user/login', '/user/register'].includes(location.pathname);
|
||||||
// The default `/` route now serves the user-facing login; admins land on `/boos`.
|
const isPublicLandingRoot = !authed && !userAuthed && location.pathname === '/';
|
||||||
const isDefaultLoginRoot = !authed && !userAuthed && location.pathname === '/';
|
|
||||||
|
|
||||||
if (isUserPage && isPublicUserPage) {
|
if (isUserPage && isPublicUserPage) {
|
||||||
if (userAuthed) return <Navigate to="/user/dashboard" />;
|
if (userAuthed) return <Navigate to="/user/dashboard" />;
|
||||||
@@ -715,8 +715,14 @@ function AppShell() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDefaultLoginRoot) {
|
if (isPublicLandingRoot) {
|
||||||
return <Navigate to="/user/login" />;
|
return (
|
||||||
|
<Suspense fallback={<RouteLoadingFallback />}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Landing />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!authed && !isUserPage) {
|
if (!authed && !isUserPage) {
|
||||||
|
|||||||
@@ -89,6 +89,612 @@ body {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Public Landing ===== */
|
||||||
|
.landing-page {
|
||||||
|
min-height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 78% 8%, rgba(31, 111, 235, 0.22), transparent 32%),
|
||||||
|
radial-gradient(circle at 12% 62%, rgba(14, 165, 145, 0.14), transparent 30%),
|
||||||
|
linear-gradient(180deg, #08111f 0%, #0b1220 48%, #0f1625 100%);
|
||||||
|
color: #f7fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-page a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-nav {
|
||||||
|
width: min(1180px, calc(100% - 40px));
|
||||||
|
height: 72px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-brand,
|
||||||
|
.landing-nav-actions,
|
||||||
|
.landing-nav-links,
|
||||||
|
.landing-hero-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-brand {
|
||||||
|
gap: 10px;
|
||||||
|
font-weight: 780;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-brand img {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-nav-links {
|
||||||
|
justify-content: center;
|
||||||
|
gap: 24px;
|
||||||
|
color: rgba(226, 232, 240, 0.72);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-nav-links a:hover {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-nav-actions,
|
||||||
|
.landing-hero-actions {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-primary-button,
|
||||||
|
.landing-secondary-button,
|
||||||
|
.landing-link-button {
|
||||||
|
min-height: 40px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 720;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: transform 0.16s ease, border-color 0.16s ease, background-color 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-primary-button {
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #0f172a;
|
||||||
|
box-shadow: 0 14px 34px rgba(5, 12, 24, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-secondary-button,
|
||||||
|
.landing-link-button {
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.2);
|
||||||
|
background: rgba(15, 23, 42, 0.42);
|
||||||
|
color: #e5edf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-link-button {
|
||||||
|
min-height: 36px;
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
color: rgba(226, 232, 240, 0.76);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-primary-button:hover,
|
||||||
|
.landing-secondary-button:hover,
|
||||||
|
.landing-link-button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-secondary-button:hover,
|
||||||
|
.landing-link-button:hover {
|
||||||
|
border-color: rgba(226, 232, 240, 0.36);
|
||||||
|
background: rgba(30, 41, 59, 0.62);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-hero {
|
||||||
|
width: min(1180px, calc(100% - 40px));
|
||||||
|
min-height: calc(100dvh - 72px);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.02fr) minmax(360px, 0.98fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 56px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 42px 0 58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-hero-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 22px;
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-brand-strip {
|
||||||
|
width: fit-content;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(15, 23, 42, 0.46);
|
||||||
|
color: rgba(226, 232, 240, 0.82);
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 720;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-hero h1 {
|
||||||
|
max-width: 780px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(42px, 6vw, 78px);
|
||||||
|
line-height: 0.98;
|
||||||
|
letter-spacing: -0.045em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-hero p,
|
||||||
|
.landing-section-heading p,
|
||||||
|
.landing-workflow-main p,
|
||||||
|
.landing-deploy p,
|
||||||
|
.landing-final p {
|
||||||
|
max-width: 62ch;
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(203, 213, 225, 0.78);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-visual {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||||
|
border-radius: 28px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.11), rgba(255, 255, 255, 0.04)),
|
||||||
|
rgba(9, 18, 32, 0.78);
|
||||||
|
padding: 18px;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.18),
|
||||||
|
0 34px 90px rgba(0, 0, 0, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-visual::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 16px;
|
||||||
|
border-radius: 22px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-visual-top,
|
||||||
|
.landing-route-row,
|
||||||
|
.landing-visual-grid > div,
|
||||||
|
.landing-node-card,
|
||||||
|
.landing-deploy-panel,
|
||||||
|
.landing-capability-card,
|
||||||
|
.landing-module-row {
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
background: rgba(15, 23, 42, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-visual-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-visual-top span,
|
||||||
|
.landing-visual-grid span,
|
||||||
|
.landing-node-card span,
|
||||||
|
.landing-deploy-panel span {
|
||||||
|
color: rgba(148, 163, 184, 0.9);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-visual-top strong,
|
||||||
|
.landing-visual-grid strong,
|
||||||
|
.landing-node-card strong,
|
||||||
|
.landing-deploy-panel strong {
|
||||||
|
color: #f8fafc;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-route-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-route-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 72px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 13px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-route-row span {
|
||||||
|
color: rgba(125, 211, 252, 0.92);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-route-row strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #f8fafc;
|
||||||
|
font-size: 14px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-route-row em {
|
||||||
|
color: rgba(203, 213, 225, 0.72);
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-visual-grid,
|
||||||
|
.landing-node-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-visual-grid > div,
|
||||||
|
.landing-node-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 104px;
|
||||||
|
align-content: end;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-section,
|
||||||
|
.landing-workflow,
|
||||||
|
.landing-deploy,
|
||||||
|
.landing-final {
|
||||||
|
width: min(1180px, calc(100% - 40px));
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-section {
|
||||||
|
padding: 64px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-section-heading {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-section-heading h2,
|
||||||
|
.landing-workflow-main h2,
|
||||||
|
.landing-deploy h2,
|
||||||
|
.landing-final h2 {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0;
|
||||||
|
color: #f8fafc;
|
||||||
|
font-size: clamp(30px, 4vw, 50px);
|
||||||
|
line-height: 1.06;
|
||||||
|
letter-spacing: -0.035em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-capability-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.15fr 0.85fr;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-capability-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
min-height: 250px;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-capability-card:first-child {
|
||||||
|
grid-row: span 2;
|
||||||
|
min-height: 514px;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(14, 165, 145, 0.2), rgba(15, 23, 42, 0.58) 46%),
|
||||||
|
rgba(15, 23, 42, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-capability-card h3,
|
||||||
|
.landing-module-row h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #f8fafc;
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-capability-card p,
|
||||||
|
.landing-module-row p {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(203, 213, 225, 0.76);
|
||||||
|
line-height: 1.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-chip-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-chip-row span {
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(2, 6, 23, 0.28);
|
||||||
|
color: rgba(226, 232, 240, 0.82);
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-workflow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 0.92fr) minmax(360px, 1.08fr);
|
||||||
|
gap: 18px;
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 36px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
border-radius: 32px;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(30, 64, 175, 0.18), transparent 42%),
|
||||||
|
rgba(15, 23, 42, 0.44);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-workflow-main {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-mode-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-mode-list div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 112px minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
color: rgba(203, 213, 225, 0.74);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-mode-list code {
|
||||||
|
border: 1px solid rgba(125, 211, 252, 0.2);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(14, 165, 233, 0.08);
|
||||||
|
color: rgba(125, 211, 252, 0.96);
|
||||||
|
padding: 6px 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-workflow-side {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-node-card.is-wide {
|
||||||
|
min-height: 150px;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(248, 250, 252, 0.1), rgba(14, 165, 145, 0.08)),
|
||||||
|
rgba(15, 23, 42, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-module-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-module-row {
|
||||||
|
min-height: 184px;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 12px;
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-deploy {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 0.92fr) minmax(320px, 0.78fr);
|
||||||
|
gap: 28px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 56px 0 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-deploy > div:first-child {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-deploy-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-deploy-panel > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
|
||||||
|
padding: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-deploy-panel > div:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-final {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 72px 0 84px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-final p {
|
||||||
|
max-width: 48ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.landing-hero-copy,
|
||||||
|
.landing-visual,
|
||||||
|
.landing-capability-card,
|
||||||
|
.landing-workflow,
|
||||||
|
.landing-module-row,
|
||||||
|
.landing-deploy,
|
||||||
|
.landing-final {
|
||||||
|
animation: landing-rise 0.72s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-visual {
|
||||||
|
animation-delay: 0.08s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-capability-card:nth-child(2),
|
||||||
|
.landing-module-row:nth-child(2) {
|
||||||
|
animation-delay: 0.04s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-capability-card:nth-child(3),
|
||||||
|
.landing-module-row:nth-child(3) {
|
||||||
|
animation-delay: 0.08s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes landing-rise {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.landing-nav {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-nav-links {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-hero,
|
||||||
|
.landing-workflow,
|
||||||
|
.landing-deploy {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-hero {
|
||||||
|
min-height: auto;
|
||||||
|
padding-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-capability-grid,
|
||||||
|
.landing-module-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-capability-card:first-child {
|
||||||
|
grid-row: auto;
|
||||||
|
min-height: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.landing-nav {
|
||||||
|
width: min(100% - 28px, 1180px);
|
||||||
|
height: auto;
|
||||||
|
padding: 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-brand span {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-nav-actions {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-link-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-primary-button,
|
||||||
|
.landing-secondary-button {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-hero,
|
||||||
|
.landing-section,
|
||||||
|
.landing-workflow,
|
||||||
|
.landing-deploy,
|
||||||
|
.landing-final {
|
||||||
|
width: min(100% - 28px, 1180px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-hero {
|
||||||
|
gap: 30px;
|
||||||
|
padding-bottom: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-hero h1 {
|
||||||
|
font-size: clamp(38px, 13vw, 54px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-hero-actions {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-visual {
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-route-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-visual-grid,
|
||||||
|
.landing-node-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-workflow {
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-mode-list div {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Animations ===== */
|
/* ===== Animations ===== */
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to {
|
to {
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { create, type ReactTestInstance } from 'react-test-renderer';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
|
||||||
|
import Landing from './Landing.js';
|
||||||
|
|
||||||
|
function collectText(node: ReactTestInstance): string {
|
||||||
|
return (node.children || []).map((child) => {
|
||||||
|
if (typeof child === 'string') return child;
|
||||||
|
return collectText(child);
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Landing page', () => {
|
||||||
|
it('presents the public BossAI website with product capabilities', () => {
|
||||||
|
const root = create(
|
||||||
|
<MemoryRouter>
|
||||||
|
<Landing />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = collectText(root.root);
|
||||||
|
expect(text).toContain('聚合多模型多模态的 AI 应用平台');
|
||||||
|
expect(text).toContain('统一模型网关');
|
||||||
|
expect(text).toContain('工作流生产台');
|
||||||
|
expect(text).toContain('短视频工厂');
|
||||||
|
expect(text).toContain('Comfy Cloud API 后端');
|
||||||
|
expect(text).toContain('Rust 二进制 + 内置 Web');
|
||||||
|
expect(text).not.toContain('\u2014');
|
||||||
|
} finally {
|
||||||
|
root.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links to user login, registration and admin access', () => {
|
||||||
|
const root = create(
|
||||||
|
<MemoryRouter>
|
||||||
|
<Landing />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const links = root.root.findAll((node) => node.type === 'a');
|
||||||
|
const hrefs = links.map((node) => node.props.href || node.props.to);
|
||||||
|
expect(hrefs).toContain('/user/login');
|
||||||
|
expect(hrefs).toContain('/user/register');
|
||||||
|
expect(hrefs).toContain('/boos');
|
||||||
|
} finally {
|
||||||
|
root.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
const capabilityGroups = [
|
||||||
|
{
|
||||||
|
title: '统一模型网关',
|
||||||
|
body: '把 OpenAI、Claude、Gemini、方舟和各类中转站收束成一个下游入口。',
|
||||||
|
items: ['/v1/chat/completions', '/v1/messages', '/v1/images/generations', '/v1/videos'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '工作流生产台',
|
||||||
|
body: '内嵌 ComfyUI 前端,按配置连接 Comfy Cloud、RunningHub、外部 ComfyUI 或 BoosAPI 执行器。',
|
||||||
|
items: ['Workflow Studio', 'Comfy Cloud', 'RunningHub', 'External Comfy'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '多模态创作应用',
|
||||||
|
body: '生图助手、视频助手、数字人和短视频工厂复用同一套模型路由与用户密钥。',
|
||||||
|
items: ['生图助手', '视频助手', '数字人', '短视频工厂'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const productModules = [
|
||||||
|
{ name: '模型路由', detail: '按站点、账号、令牌和模型调度,失败冷却后自动切换通道。' },
|
||||||
|
{ name: '模型广场', detail: '普通用户只看品牌和可用模型,管理员查看完整供应商与账号覆盖。' },
|
||||||
|
{ name: '使用日志', detail: '记录延迟、模型、计费和调用来源,便于对账和排障。' },
|
||||||
|
{ name: '工作空间', detail: '归档图片、视频、音频和任务产出,用户之间保持隔离。' },
|
||||||
|
{ name: '公告系统', detail: '管理员发布公告,普通用户登录后可直接弹窗确认。' },
|
||||||
|
{ name: '数据库后端', detail: '支持 SQLite 与 PostgreSQL,并保留迁移工具和运行时检测。' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const workflowModes = [
|
||||||
|
{ mode: 'cloud', label: 'Comfy Cloud API 后端' },
|
||||||
|
{ mode: 'runninghub', label: 'RunningHub API 后端' },
|
||||||
|
{ mode: 'external', label: '外部完整版 ComfyUI' },
|
||||||
|
{ mode: 'boosapi', label: 'Rust API-native 执行器' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const previewRows = [
|
||||||
|
{ label: 'Chat', model: 'gpt-5.4', status: '已路由' },
|
||||||
|
{ label: 'Image', model: 'gpt-image-2', status: '生成中' },
|
||||||
|
{ label: 'Video', model: 'seedance', status: '排队中' },
|
||||||
|
{ label: 'Claude', model: '/v1/messages', status: '流式' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Landing() {
|
||||||
|
return (
|
||||||
|
<main className="landing-page">
|
||||||
|
<nav className="landing-nav" aria-label="官网导航">
|
||||||
|
<Link to="/" className="landing-brand" aria-label="BossAI 首页">
|
||||||
|
<img src="/logo.png" alt="BossAI" />
|
||||||
|
<span>BossAI</span>
|
||||||
|
</Link>
|
||||||
|
<div className="landing-nav-links">
|
||||||
|
<a href="#platform">平台能力</a>
|
||||||
|
<a href="#workflow">工作流</a>
|
||||||
|
<a href="#deploy">部署形态</a>
|
||||||
|
</div>
|
||||||
|
<div className="landing-nav-actions">
|
||||||
|
<Link to="/user/login" className="landing-link-button">用户登录</Link>
|
||||||
|
<Link to="/boos" className="landing-primary-button">进入后台</Link>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section className="landing-hero">
|
||||||
|
<div className="landing-hero-copy">
|
||||||
|
<div className="landing-brand-strip">多模型 · 多模态 · 工作流</div>
|
||||||
|
<h1>聚合多模型多模态的 AI 应用平台</h1>
|
||||||
|
<p>
|
||||||
|
统一 API 网关、智能路由、Comfy 工作流和创作应用,让团队用一套后台管理对话、生图、视频和数字人生产。
|
||||||
|
</p>
|
||||||
|
<div className="landing-hero-actions">
|
||||||
|
<Link to="/user/register" className="landing-primary-button">开始使用</Link>
|
||||||
|
<Link to="/user/login" className="landing-secondary-button">用户登录</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="landing-visual" aria-label="BossAI 平台预览">
|
||||||
|
<div className="landing-visual-top">
|
||||||
|
<span>Unified Gateway</span>
|
||||||
|
<strong>路由在线</strong>
|
||||||
|
</div>
|
||||||
|
<div className="landing-route-stack">
|
||||||
|
{previewRows.map((row) => (
|
||||||
|
<div key={row.label} className="landing-route-row">
|
||||||
|
<span>{row.label}</span>
|
||||||
|
<strong>{row.model}</strong>
|
||||||
|
<em>{row.status}</em>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="landing-visual-grid">
|
||||||
|
<div>
|
||||||
|
<span>Workspace</span>
|
||||||
|
<strong>归档产出</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Billing</span>
|
||||||
|
<strong>日志计费</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="platform" className="landing-section landing-capabilities">
|
||||||
|
<div className="landing-section-heading">
|
||||||
|
<h2>从 API 到内容生产,一条链路打通</h2>
|
||||||
|
<p>BossAI 不是只转发请求。它把模型接入、路由、用户、工作流和产出归档放在同一个运行面里。</p>
|
||||||
|
</div>
|
||||||
|
<div className="landing-capability-grid">
|
||||||
|
{capabilityGroups.map((group) => (
|
||||||
|
<article key={group.title} className="landing-capability-card">
|
||||||
|
<h3>{group.title}</h3>
|
||||||
|
<p>{group.body}</p>
|
||||||
|
<div className="landing-chip-row">
|
||||||
|
{group.items.map((item) => <span key={item}>{item}</span>)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="workflow" className="landing-workflow">
|
||||||
|
<div className="landing-workflow-main">
|
||||||
|
<h2>用熟悉的 ComfyUI 前端,接你的云端 API</h2>
|
||||||
|
<p>
|
||||||
|
工作流页面可切换 Cloud、RunningHub、External 和 BoosAPI 后端。你继续使用熟悉的节点画布,BossAI 负责鉴权、代理、任务记录和工作空间归档。
|
||||||
|
</p>
|
||||||
|
<div className="landing-mode-list">
|
||||||
|
{workflowModes.map((item) => (
|
||||||
|
<div key={item.mode}>
|
||||||
|
<code>{item.mode}</code>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="landing-workflow-side">
|
||||||
|
<div className="landing-node-card is-wide">
|
||||||
|
<span>Prompt</span>
|
||||||
|
<strong>商品文案、角色设定、镜头脚本</strong>
|
||||||
|
</div>
|
||||||
|
<div className="landing-node-grid">
|
||||||
|
<div className="landing-node-card">
|
||||||
|
<span>Image</span>
|
||||||
|
<strong>图像生成</strong>
|
||||||
|
</div>
|
||||||
|
<div className="landing-node-card">
|
||||||
|
<span>Video</span>
|
||||||
|
<strong>视频任务</strong>
|
||||||
|
</div>
|
||||||
|
<div className="landing-node-card">
|
||||||
|
<span>Agent</span>
|
||||||
|
<strong>视频助手</strong>
|
||||||
|
</div>
|
||||||
|
<div className="landing-node-card">
|
||||||
|
<span>Archive</span>
|
||||||
|
<strong>工作空间</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="landing-section">
|
||||||
|
<div className="landing-module-grid">
|
||||||
|
{productModules.map((module) => (
|
||||||
|
<article key={module.name} className="landing-module-row">
|
||||||
|
<h3>{module.name}</h3>
|
||||||
|
<p>{module.detail}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="deploy" className="landing-deploy">
|
||||||
|
<div>
|
||||||
|
<h2>远程模型优先,本机不必有 GPU</h2>
|
||||||
|
<p>
|
||||||
|
当图像、视频和语音能力走上游 API、Comfy Cloud 或 RunningHub 时,BossAI 服务器只负责网关、数据库、Workflow UI 代理、日志和归档。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="landing-deploy-panel">
|
||||||
|
<div>
|
||||||
|
<span>推荐形态</span>
|
||||||
|
<strong>Rust 二进制 + 内置 Web</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>数据库</span>
|
||||||
|
<strong>SQLite / PostgreSQL</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>执行能力</span>
|
||||||
|
<strong>API、Cloud、RunningHub、External</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="landing-final">
|
||||||
|
<h2>把模型、工作流和创作应用收进一个平台</h2>
|
||||||
|
<p>适合自建 AI 中转、团队模型管理、多模态创作和商业化用户面板。</p>
|
||||||
|
<div className="landing-hero-actions">
|
||||||
|
<Link to="/user/register" className="landing-primary-button">开始使用</Link>
|
||||||
|
<Link to="/boos" className="landing-secondary-button">进入后台</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user