feat: implement new layout components and routing structure
- Added HeaderLayout component for the application header. - Introduced KeepAliveViewport for managing tab states and rendering. - Created TabNav for tab navigation with animated indicator. - Removed old Tabs component in favor of new layout structure. - Updated routing with AppRouter and defined appTabs for navigation. - Enhanced theme context to manage dark mode styles. - Added new UI components: Badge, Button, Separator, and Tabs. - Refactored pages to utilize new layout components and improve responsiveness. - Updated global styles for better theming and layout consistency. - Introduced TypeScript path aliases for cleaner imports.
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -59,3 +59,6 @@ Thumbs.db
|
||||
|
||||
# logs files
|
||||
logs/
|
||||
|
||||
# codex
|
||||
.agents
|
||||
@@ -4,6 +4,12 @@
|
||||
|
||||
- Backend code lives under `backend/app/`; frontend is the Vite app in `frontend/`.
|
||||
|
||||
## Frontend UX Constraints
|
||||
|
||||
- Frontend work in `frontend/` must target desktop Web first.
|
||||
- Do not proactively add mobile-specific adaptations, responsive reflow for small screens, or mobile-first layout compromises unless the user explicitly asks for them.
|
||||
- When desktop and mobile requirements conflict, preserve the desktop Web layout and interaction model by default.
|
||||
|
||||
## Entrypoints
|
||||
|
||||
- Backend entrypoint is `backend/app/main.py`, which re-exports `app` from `app.api.main`.
|
||||
|
||||
@@ -381,5 +381,41 @@
|
||||
"processing_stage": "indexed",
|
||||
"index_collection": "regulations_dense_1024_v1"
|
||||
}
|
||||
},
|
||||
"52bd970f": {
|
||||
"doc_id": "52bd970f",
|
||||
"doc_name": "使用RSA Token连接CheckPoint VPN及PIN码设置_220.181.114.93 or 10.25.134.3.docx",
|
||||
"file_name": "使用RSA Token连接CheckPoint VPN及PIN码设置_220.181.114.93 or 10.25.134.3.docx",
|
||||
"object_name": "52bd970f/使用RSA Token连接CheckPoint VPN及PIN码设置_220.181.114.93 or 10.25.134.3.docx",
|
||||
"content_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"size_bytes": 1199920,
|
||||
"status": "indexed",
|
||||
"regulation_type": "",
|
||||
"version": "",
|
||||
"summary": "",
|
||||
"summary_latency_ms": 0,
|
||||
"chunk_count": 34,
|
||||
"parser_name": "aliyun_docmind",
|
||||
"index_name": "regulations_dense_1024_v1",
|
||||
"error_message": "",
|
||||
"created_at": "2026-05-25T07:45:12.777459+00:00",
|
||||
"updated_at": "2026-05-25T07:45:37.314290+00:00",
|
||||
"metadata": {
|
||||
"generate_summary": true,
|
||||
"parser_backend": "aliyun_docmind",
|
||||
"parse_task_id": "docmind-20260525-6d782dc33f2748a4a1020df765b8182d",
|
||||
"layout_count": 48,
|
||||
"structure_node_count": 6,
|
||||
"semantic_block_count": 33,
|
||||
"vector_chunk_count": 34,
|
||||
"artifact_keys": {
|
||||
"layouts": "artifacts/52bd970f/layouts.json",
|
||||
"structure_nodes": "artifacts/52bd970f/structure_nodes.json",
|
||||
"semantic_blocks": "artifacts/52bd970f/semantic_blocks.json",
|
||||
"vector_chunks": "artifacts/52bd970f/vector_chunks.json"
|
||||
},
|
||||
"processing_stage": "indexed",
|
||||
"index_collection": "regulations_dense_1024_v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
25
frontend/components.json
Normal file
25
frontend/components.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "radix-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/shadcn/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
67
frontend/components/ui/button.tsx
Normal file
67
frontend/components/ui/button.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button }
|
||||
60
frontend/package-lock.json
generated
60
frontend/package-lock.json
generated
@@ -9,7 +9,8 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.9.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
@@ -1631,6 +1632,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -2751,6 +2765,44 @@
|
||||
"react": "^19.2.5"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.15.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz",
|
||||
"integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.15.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz",
|
||||
"integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.15.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
|
||||
@@ -2808,6 +2860,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
|
||||
@@ -10,8 +10,17 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"shadcn": "^4.8.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
|
||||
4076
frontend/pnpm-lock.yaml
generated
4076
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,49 +1,11 @@
|
||||
import './styles/globals.css';
|
||||
import { ThemeProvider, AppProvider, useApp, useTheme } from './contexts';
|
||||
import { Header, Tabs } from './components/layout';
|
||||
import { CompliancePage } from './pages/Compliance';
|
||||
import { DocsPage } from './pages/Docs';
|
||||
import { StatusPage } from './pages/Status';
|
||||
import { RagChatPage } from './pages/RagChat';
|
||||
import { PerceptionPage } from './pages/Perception';
|
||||
|
||||
const PageContent = () => {
|
||||
const { activeTab } = useApp();
|
||||
|
||||
switch (activeTab) {
|
||||
case 'perception':
|
||||
return <PerceptionPage />;
|
||||
case 'docs':
|
||||
return <DocsPage />;
|
||||
case 'compliance':
|
||||
return <CompliancePage />;
|
||||
case 'status':
|
||||
return <StatusPage />;
|
||||
case 'rag':
|
||||
return <RagChatPage />;
|
||||
default:
|
||||
return <PerceptionPage />;
|
||||
}
|
||||
};
|
||||
|
||||
const AppContent = () => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col min-h-screen" style={{ backgroundColor: theme.bg }}>
|
||||
<Header />
|
||||
<Tabs />
|
||||
<PageContent />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import { ThemeProvider } from './contexts';
|
||||
import { AppRouter } from './router/AppRouter';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AppProvider>
|
||||
<AppContent />
|
||||
</AppProvider>
|
||||
<AppRouter />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,25 @@
|
||||
import React from 'react';
|
||||
import { MoonStar, SunMedium } from 'lucide-react';
|
||||
|
||||
import { useTheme } from '../../contexts';
|
||||
import { Button } from '../shadcn/ui/button';
|
||||
|
||||
export const ThemeToggle: React.FC = () => {
|
||||
const { isDark, toggleTheme, theme } = useTheme();
|
||||
const { isDark, toggleTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
onClick={toggleTheme}
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 10,
|
||||
background: isDark ? theme.bgHover : theme.bgCard,
|
||||
border: `1px solid ${theme.border}`,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
variant="outline"
|
||||
size="icon-lg"
|
||||
className="rounded-xl border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{isDark ? (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="4" fill={theme.accent}/>
|
||||
<path d="M12 2V4M12 20V22M4 12H2M22 12H20M6.34 6.34L4.93 4.93M19.07 19.07L17.66 17.66M6.34 17.66L4.93 19.07M19.07 4.93L17.66 6.34" stroke={theme.accent} strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
<SunMedium />
|
||||
) : (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" fill={theme.accent} stroke={theme.accent} strokeWidth="1"/>
|
||||
</svg>
|
||||
<MoonStar />
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
23
frontend/src/components/layout/AppShell.tsx
Normal file
23
frontend/src/components/layout/AppShell.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
|
||||
import { FooterLayout } from './FooterLayout';
|
||||
import { HeaderLayout } from './HeaderLayout';
|
||||
import { ContentLayout } from './ContentLayout';
|
||||
import { KeepAliveViewport } from './KeepAliveViewport';
|
||||
import { getTabByPath } from '../../router/tabs';
|
||||
|
||||
export function AppShell() {
|
||||
const location = useLocation();
|
||||
const activeTab = getTabByPath(location.pathname);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-t-bg text-t-text">
|
||||
<HeaderLayout activeTab={activeTab} />
|
||||
<ContentLayout tab={activeTab}>
|
||||
<KeepAliveViewport activeTab={activeTab} />
|
||||
<Outlet />
|
||||
</ContentLayout>
|
||||
<FooterLayout />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../../contexts';
|
||||
|
||||
interface ContentProps {
|
||||
children: React.ReactNode;
|
||||
wide?: boolean;
|
||||
}
|
||||
|
||||
export const Content: React.FC<ContentProps> = ({ children, wide = false }) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<main
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '48px 56px',
|
||||
maxWidth: wide ? 1400 : 1100,
|
||||
margin: '0 auto',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
backgroundColor: theme.bg,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
40
frontend/src/components/layout/ContentLayout.tsx
Normal file
40
frontend/src/components/layout/ContentLayout.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type { AppTabConfig } from '../../router/tabs';
|
||||
import { shellFrameClassName } from './shell-config';
|
||||
|
||||
interface ContentLayoutProps {
|
||||
children: ReactNode;
|
||||
tab: AppTabConfig;
|
||||
}
|
||||
|
||||
const widthClassMap = {
|
||||
default: 'mx-auto w-full max-w-[1120px]',
|
||||
wide: 'mx-auto w-full max-w-[1440px]',
|
||||
full: 'w-full',
|
||||
} as const;
|
||||
|
||||
export function ContentLayout({ children, tab }: ContentLayoutProps) {
|
||||
const widthClass = widthClassMap[tab.contentWidth];
|
||||
|
||||
return (
|
||||
<main className="flex min-h-0 flex-1 bg-t-bg">
|
||||
<div
|
||||
className={[
|
||||
shellFrameClassName,
|
||||
'relative flex min-h-0 flex-1 py-8',
|
||||
].join(' ')}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
'relative flex w-full min-h-0 flex-1',
|
||||
widthClass,
|
||||
tab.fillHeight ? 'overflow-hidden' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
38
frontend/src/components/layout/FooterLayout.tsx
Normal file
38
frontend/src/components/layout/FooterLayout.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Badge } from '../shadcn/ui/badge';
|
||||
import { Separator } from '../shadcn/ui/separator';
|
||||
|
||||
import { shellFrameClassName, shellMeta } from './shell-config';
|
||||
|
||||
export function FooterLayout() {
|
||||
return (
|
||||
<footer className="border-t border-t-border bg-t-bg">
|
||||
<div
|
||||
className={[
|
||||
shellFrameClassName,
|
||||
'flex items-center justify-between gap-6 py-4 text-xs text-t-text3',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="min-w-0 max-w-[360px]">
|
||||
<div className="mono mb-1 tracking-[0.18em] text-t-text2">
|
||||
{shellMeta.productLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-3 whitespace-nowrap rounded-xl border border-border bg-card px-3 py-2 shadow-sm">
|
||||
<Badge variant="secondary" className="mono border-0 bg-transparent px-0 py-0 text-[11px] tracking-[0.24em] text-muted-foreground">
|
||||
{shellMeta.version}
|
||||
</Badge>
|
||||
<Separator orientation="vertical" className="h-4 bg-border" />
|
||||
<Badge variant="outline" className="mono gap-2 border-0 bg-transparent px-0 py-0 text-[11px] tracking-[0.24em] text-[var(--t-green)]">
|
||||
<span className="size-2 rounded-full bg-[var(--t-green)]" />
|
||||
{shellMeta.status}
|
||||
</Badge>
|
||||
<Separator orientation="vertical" className="h-4 bg-border" />
|
||||
<span className="mono text-[11px] tracking-[0.18em] text-muted-foreground">
|
||||
{shellMeta.surface}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../../contexts';
|
||||
import { TLogo } from '../common/TLogo';
|
||||
import { ThemeToggle } from '../common/ThemeToggle';
|
||||
|
||||
export const Header: React.FC = () => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<header
|
||||
className="h-[72px] flex items-center justify-between sticky top-0 z-[100]"
|
||||
style={{
|
||||
padding: '0 48px',
|
||||
borderBottom: `1px solid ${theme.border}`,
|
||||
backgroundColor: theme.bg,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center" style={{ gap: 20 }}>
|
||||
<TLogo size={80} />
|
||||
<div className="flex items-baseline" style={{ gap: 12 }}>
|
||||
<span style={{ fontWeight: 700, fontSize: 20, letterSpacing: '-0.5px', color: theme.text }}>
|
||||
T-Systems
|
||||
</span>
|
||||
<span style={{ fontWeight: 300, fontSize: 16, color: theme.text2 }}>
|
||||
Regulation
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center" style={{ gap: 16 }}>
|
||||
<ThemeToggle />
|
||||
<div
|
||||
className="flex items-center rounded-lg"
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
gap: 8,
|
||||
backgroundColor: theme.bgHover,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<span className="mono" style={{ fontSize: 11, color: theme.text3 }}>v1.0.0</span>
|
||||
<div style={{ width: 1, height: 12, background: theme.border }} />
|
||||
<span className="mono" style={{ fontSize: 12, color: theme.green }}>● ONLINE</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
19
frontend/src/components/layout/HeaderBrand.tsx
Normal file
19
frontend/src/components/layout/HeaderBrand.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { TLogo } from '../common/TLogo';
|
||||
|
||||
export function HeaderBrand() {
|
||||
return (
|
||||
<div className="flex min-w-[280px] shrink-0 items-center gap-4 whitespace-nowrap">
|
||||
<div className="shrink-0">
|
||||
<TLogo size={46} />
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2 whitespace-nowrap">
|
||||
<span className="text-[1.18rem] font-semibold tracking-[-0.04em] text-foreground">
|
||||
T-Systems
|
||||
</span>
|
||||
<span className="text-[1.02rem] font-light text-muted-foreground">
|
||||
Regulation
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
frontend/src/components/layout/HeaderLayout.tsx
Normal file
38
frontend/src/components/layout/HeaderLayout.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { AppTabConfig } from '../../router/tabs';
|
||||
import { ThemeToggle } from '../common/ThemeToggle';
|
||||
import { Badge } from '../shadcn/ui/badge';
|
||||
import { Separator } from '../shadcn/ui/separator';
|
||||
|
||||
import { HeaderBrand } from './HeaderBrand';
|
||||
import { shellFrameClassName, shellMeta } from './shell-config';
|
||||
import { TabNav } from './TabNav';
|
||||
|
||||
interface HeaderLayoutProps {
|
||||
activeTab: AppTabConfig;
|
||||
}
|
||||
|
||||
export function HeaderLayout({ activeTab }: HeaderLayoutProps) {
|
||||
return (
|
||||
<header className="sticky top-0 z-[100] border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<div className={[shellFrameClassName, 'flex h-20 items-center gap-8'].join(' ')}>
|
||||
<HeaderBrand />
|
||||
<div className="min-w-0 flex-1 self-stretch overflow-hidden">
|
||||
<TabNav activeTab={activeTab} />
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-3 self-center">
|
||||
<ThemeToggle />
|
||||
<div className="flex h-11 shrink-0 items-center gap-3 whitespace-nowrap rounded-xl border border-border bg-card px-3 shadow-sm">
|
||||
<Badge variant="secondary" className="mono border-0 bg-transparent px-0 py-0 text-[11px] tracking-[0.24em] text-muted-foreground">
|
||||
{shellMeta.version}
|
||||
</Badge>
|
||||
<Separator orientation="vertical" className="h-4 bg-border" />
|
||||
<Badge variant="outline" className="mono gap-2 border-0 bg-transparent px-0 py-0 text-[11px] tracking-[0.24em] text-[var(--t-green)]">
|
||||
<span className="size-2 rounded-full bg-[var(--t-green)]" />
|
||||
{shellMeta.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
45
frontend/src/components/layout/KeepAliveViewport.tsx
Normal file
45
frontend/src/components/layout/KeepAliveViewport.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { appTabs, type AppTabConfig } from '../../router/tabs';
|
||||
|
||||
interface KeepAliveViewportProps {
|
||||
activeTab: AppTabConfig;
|
||||
}
|
||||
|
||||
export function KeepAliveViewport({ activeTab }: KeepAliveViewportProps) {
|
||||
const [mountedTabIds, setMountedTabIds] = useState<string[]>([activeTab.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const timerId = window.setTimeout(() => {
|
||||
setMountedTabIds((prev) => (prev.includes(activeTab.id) ? prev : [...prev, activeTab.id]));
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timerId);
|
||||
}, [activeTab.id]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{appTabs.map((tab) => {
|
||||
const shouldRender = tab.keepAlive ? mountedTabIds.includes(tab.id) : tab.id === activeTab.id;
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const TabComponent = tab.component;
|
||||
const isActive = tab.id === activeTab.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
aria-hidden={!isActive}
|
||||
className={[
|
||||
'min-h-0 flex-1',
|
||||
isActive ? 'flex' : 'hidden',
|
||||
].join(' ')}
|
||||
>
|
||||
<TabComponent />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
frontend/src/components/layout/TabNav.tsx
Normal file
125
frontend/src/components/layout/TabNav.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import type { AppTabConfig, TabId } from '../../router/tabs';
|
||||
import { appTabs } from '../../router/tabs';
|
||||
|
||||
interface TabNavProps {
|
||||
activeTab: AppTabConfig;
|
||||
}
|
||||
|
||||
interface IndicatorStyle {
|
||||
opacity: number;
|
||||
transform: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
const reducedMotionQuery = '(prefers-reduced-motion: reduce)';
|
||||
|
||||
export function TabNav({ activeTab }: TabNavProps) {
|
||||
const navigate = useNavigate();
|
||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||
const buttonRefs = useRef<Record<TabId, HTMLButtonElement | null>>({
|
||||
perception: null,
|
||||
docs: null,
|
||||
compliance: null,
|
||||
status: null,
|
||||
rag: null,
|
||||
});
|
||||
const [indicatorStyle, setIndicatorStyle] = useState<IndicatorStyle>({
|
||||
opacity: 0,
|
||||
transform: 'translateX(0px)',
|
||||
width: 0,
|
||||
});
|
||||
const [reducedMotion, setReducedMotion] = useState(false);
|
||||
|
||||
const handleValueChange = (value: string) => {
|
||||
const nextTab = appTabs.find((tab) => tab.id === value);
|
||||
if (nextTab && nextTab.path !== activeTab.path) {
|
||||
navigate(nextTab.path);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia(reducedMotionQuery);
|
||||
const updateMotionPreference = () => {
|
||||
setReducedMotion(mediaQuery.matches);
|
||||
};
|
||||
|
||||
updateMotionPreference();
|
||||
mediaQuery.addEventListener('change', updateMotionPreference);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', updateMotionPreference);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const updateIndicator = () => {
|
||||
const trackNode = trackRef.current;
|
||||
const activeNode = buttonRefs.current[activeTab.id];
|
||||
if (!trackNode || !activeNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trackRect = trackNode.getBoundingClientRect();
|
||||
const activeRect = activeNode.getBoundingClientRect();
|
||||
|
||||
setIndicatorStyle({
|
||||
opacity: 1,
|
||||
transform: `translateX(${activeRect.left - trackRect.left}px)`,
|
||||
width: activeRect.width,
|
||||
});
|
||||
};
|
||||
|
||||
updateIndicator();
|
||||
window.addEventListener('resize', updateIndicator);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateIndicator);
|
||||
};
|
||||
}, [activeTab.id]);
|
||||
|
||||
return (
|
||||
<nav className="flex h-full min-w-0 items-stretch overflow-x-auto overflow-y-hidden">
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="relative flex h-full min-w-max flex-nowrap items-stretch gap-3 pr-6"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={[
|
||||
'pointer-events-none absolute bottom-0 left-0 h-0.5 rounded-full bg-primary',
|
||||
reducedMotion
|
||||
? 'transition-none'
|
||||
: 'transition-[transform,width,opacity] duration-220 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||
].join(' ')}
|
||||
style={indicatorStyle}
|
||||
/>
|
||||
{appTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
ref={(node) => {
|
||||
buttonRefs.current[tab.id] = node;
|
||||
}}
|
||||
data-shell-tab="true"
|
||||
type="button"
|
||||
onClick={() => handleValueChange(tab.id satisfies TabId)}
|
||||
aria-current={tab.id === activeTab.id ? 'page' : undefined}
|
||||
className={[
|
||||
'inline-flex h-full shrink-0 appearance-none items-center justify-center border-0 border-b-2 border-transparent bg-transparent px-5 pt-1 text-[0.95rem] font-medium tracking-[0.02em] outline-none',
|
||||
reducedMotion
|
||||
? 'transition-none'
|
||||
: 'transition-[color,opacity] duration-200 ease-out',
|
||||
tab.id === activeTab.id
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
].join(' ')}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTheme, useApp } from '../../contexts';
|
||||
import type { TabId } from '../../contexts';
|
||||
|
||||
const tabs: Array<{ id: TabId; label: string }> = [
|
||||
{ id: 'perception', label: '智能感知' },
|
||||
{ id: 'docs', label: '文档管理' },
|
||||
{ id: 'compliance', label: '合规分析' },
|
||||
{ id: 'status', label: '系统状态' },
|
||||
{ id: 'rag', label: '法规对话' },
|
||||
];
|
||||
|
||||
export const Tabs: React.FC = () => {
|
||||
const { theme } = useTheme();
|
||||
const { activeTab, setActiveTab } = useApp();
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="h-[56px] flex items-center"
|
||||
style={{
|
||||
padding: '0 48px',
|
||||
borderBottom: `1px solid ${theme.border}`,
|
||||
backgroundColor: theme.bg,
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
style={{
|
||||
height: 56,
|
||||
padding: '0 32px',
|
||||
fontSize: 15,
|
||||
fontWeight: activeTab === tab.id ? 600 : 400,
|
||||
color: activeTab === tab.id ? theme.accent : theme.text3,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === tab.id ? `3px solid ${theme.accent}` : '3px solid transparent',
|
||||
marginBottom: -1,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export { Header } from './Header';
|
||||
export { Tabs } from './Tabs';
|
||||
export { Content } from './Content';
|
||||
export { AppShell } from './AppShell';
|
||||
export { ContentLayout } from './ContentLayout';
|
||||
export { FooterLayout } from './FooterLayout';
|
||||
export { HeaderLayout } from './HeaderLayout';
|
||||
|
||||
12
frontend/src/components/layout/shell-config.ts
Normal file
12
frontend/src/components/layout/shell-config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { appTabs } from '../../router/tabs';
|
||||
|
||||
export const shellFrameClassName = 'mx-auto w-full max-w-[1680px] px-8';
|
||||
|
||||
export const shellMeta = {
|
||||
productLabel: 'T-Systems Regulation',
|
||||
version: 'v1.0.0',
|
||||
status: 'ONLINE',
|
||||
surface: 'Desktop Web',
|
||||
} as const;
|
||||
|
||||
export const shellModuleSummary = appTabs.map((tab) => tab.label).join(' / ');
|
||||
30
frontend/src/components/shadcn/ui/badge.tsx
Normal file
30
frontend/src/components/shadcn/ui/badge.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import type * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-md border px-2 py-1 text-[11px] font-medium tracking-[0.22em] uppercase transition-colors',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-primary/30 bg-primary/10 text-primary',
|
||||
secondary: 'border-border bg-muted text-muted-foreground',
|
||||
outline: 'border-border bg-transparent text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants>) {
|
||||
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge };
|
||||
65
frontend/src/components/shadcn/ui/button.tsx
Normal file
65
frontend/src/components/shadcn/ui/button.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Slot } from 'radix-ui';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-4',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
outline:
|
||||
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30 dark:hover:bg-input/50',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
||||
ghost:
|
||||
'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
|
||||
destructive:
|
||||
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||
xs: 'h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3',
|
||||
sm: 'h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3.5',
|
||||
lg: 'h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||
icon: 'size-8',
|
||||
'icon-xs': 'size-6 rounded-[min(var(--radius-md),10px)] [&_svg:not([class*=size-])]:size-3',
|
||||
'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)]',
|
||||
'icon-lg': 'size-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : 'button';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button };
|
||||
27
frontend/src/components/shadcn/ui/separator.tsx
Normal file
27
frontend/src/components/shadcn/ui/separator.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from 'react';
|
||||
import { Separator as SeparatorPrimitive } from 'radix-ui';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = 'horizontal',
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator-root"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
48
frontend/src/components/shadcn/ui/tabs.tsx
Normal file
48
frontend/src/components/shadcn/ui/tabs.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as React from 'react';
|
||||
import { Tabs as TabsPrimitive } from 'radix-ui';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn('w-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn('inline-flex items-center gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger };
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
|
||||
import { AppContext, type TabId } from './app-context';
|
||||
|
||||
interface AppProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AppProvider: React.FC<AppProviderProps> = ({ children }) => {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('compliance');
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={{ activeTab, setActiveTab }}>
|
||||
{children}
|
||||
</AppContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -16,13 +16,14 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
document.body.classList.toggle('dark-mode', isDark);
|
||||
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.body.style.background = '#0a0a12';
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.body.style.background = '#ffffff';
|
||||
}, [isDark]);
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export type TabId = 'perception' | 'docs' | 'compliance' | 'status' | 'rag';
|
||||
|
||||
export interface AppContextValue {
|
||||
activeTab: TabId;
|
||||
setActiveTab: (tab: TabId) => void;
|
||||
}
|
||||
|
||||
export const AppContext = createContext<AppContextValue | undefined>(undefined);
|
||||
@@ -1,5 +1,2 @@
|
||||
export { ThemeProvider } from './ThemeContext';
|
||||
export { useTheme } from './useTheme';
|
||||
export { AppProvider } from './AppContext';
|
||||
export type { AppContextValue, TabId } from './app-context';
|
||||
export { useApp } from './useApp';
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { AppContext, type AppContextValue } from './app-context';
|
||||
|
||||
export function useApp(): AppContextValue {
|
||||
const context = useContext(AppContext);
|
||||
if (!context) {
|
||||
throw new Error('useApp must be used within an AppProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -587,7 +587,7 @@ export const CompliancePage: React.FC = () => {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
minHeight: 'calc(100vh - 128px)',
|
||||
minHeight: 0,
|
||||
position: 'relative',
|
||||
}}>
|
||||
{/* Main Content Area */}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTheme } from '../../contexts';
|
||||
import { Content } from '../../components/layout/Content';
|
||||
import { TPattern } from '../../components/common/TPattern';
|
||||
import { getDocumentList, getDocumentStatus, searchRegulations, uploadDocument, deleteDocument, retryDocument, type RegulationSearchItem } from '../../api/docs';
|
||||
import type { Doc } from '../../types';
|
||||
@@ -40,6 +39,7 @@ export const DocsPage: React.FC = () => {
|
||||
const [searchResults, setSearchResults] = useState<RegulationSearchItem[]>([]);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [searchError, setSearchError] = useState('');
|
||||
const [batchQueueLength, setBatchQueueLength] = useState(0);
|
||||
|
||||
// Upload metadata
|
||||
const [regulationType, setRegulationType] = useState('');
|
||||
@@ -48,12 +48,17 @@ export const DocsPage: React.FC = () => {
|
||||
// Batch queue: files waiting to be uploaded after the current one finishes
|
||||
const batchQueueRef = useRef<File[]>([]);
|
||||
|
||||
const setBatchQueue = (files: File[]) => {
|
||||
batchQueueRef.current = files;
|
||||
setBatchQueueLength(files.length);
|
||||
};
|
||||
|
||||
async function loadDocuments() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getDocumentList();
|
||||
const apiDocs: Doc[] = response.docs.map((doc) => ({
|
||||
id: parseInt(String(doc.id).replace('doc-', ''), 10) || Math.floor(Math.random() * 10000),
|
||||
const apiDocs: Doc[] = response.docs.map((doc, index) => ({
|
||||
id: Number.parseInt(String(doc.id).replace('doc-', ''), 10) || -(index + 1),
|
||||
name: doc.name,
|
||||
chunks: doc.chunks,
|
||||
size: doc.updated_at ? new Date(doc.updated_at).toLocaleString() : 'Indexed document',
|
||||
@@ -209,6 +214,7 @@ export const DocsPage: React.FC = () => {
|
||||
|
||||
// Process next file in batch queue
|
||||
const next = batchQueueRef.current.shift();
|
||||
setBatchQueueLength(batchQueueRef.current.length);
|
||||
if (next) {
|
||||
const nextRunId = pipelineRunIdRef.current + 1;
|
||||
pipelineRunIdRef.current = nextRunId;
|
||||
@@ -222,7 +228,7 @@ export const DocsPage: React.FC = () => {
|
||||
if (files.length === 0 || uploading) return;
|
||||
|
||||
const [first, ...rest] = files;
|
||||
batchQueueRef.current = rest;
|
||||
setBatchQueue(rest);
|
||||
|
||||
const runId = pipelineRunIdRef.current + 1;
|
||||
pipelineRunIdRef.current = runId;
|
||||
@@ -262,7 +268,7 @@ export const DocsPage: React.FC = () => {
|
||||
if (files.length === 0 || uploading) return;
|
||||
|
||||
const [first, ...rest] = files;
|
||||
batchQueueRef.current = rest;
|
||||
setBatchQueue(rest);
|
||||
const runId = pipelineRunIdRef.current + 1;
|
||||
pipelineRunIdRef.current = runId;
|
||||
void uploadSingleFile(first, runId);
|
||||
@@ -282,8 +288,7 @@ export const DocsPage: React.FC = () => {
|
||||
|
||||
const getPipelineHint = () => {
|
||||
if (pipelineStatus === 'running') {
|
||||
const queueLen = batchQueueRef.current.length;
|
||||
const suffix = queueLen > 0 ? ` (+${queueLen} 待上传)` : '';
|
||||
const suffix = batchQueueLength > 0 ? ` (+${batchQueueLength} 待上传)` : '';
|
||||
return `${activeStep >= 0 ? PIPELINE_STEPS[activeStep].name : 'LOAD'} · ${uploadFileName}${suffix}`;
|
||||
}
|
||||
if (pipelineStatus === 'completed') return 'PIPELINE COMPLETE';
|
||||
@@ -291,6 +296,11 @@ export const DocsPage: React.FC = () => {
|
||||
return 'WAITING FOR UPLOAD';
|
||||
};
|
||||
|
||||
const getDocKey = (doc: Doc) => {
|
||||
// Prefer the backend document identifier because the numeric display id is not guaranteed unique.
|
||||
return doc.docId ?? `local-${doc.id}-${doc.name}`;
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
padding: '8px 12px',
|
||||
fontSize: 13,
|
||||
@@ -302,7 +312,7 @@ export const DocsPage: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Content>
|
||||
<div className="relative w-full">
|
||||
<TPattern />
|
||||
|
||||
<section style={{ marginBottom: 56 }}>
|
||||
@@ -432,7 +442,7 @@ export const DocsPage: React.FC = () => {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{docs.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
key={getDocKey(doc)}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: 20, background: theme.bgCard, borderRadius: 12, border: `1px solid ${doc.status === 'parsing' ? theme.accent : theme.border}`, transition: 'all 0.2s ease', boxShadow: !isDark ? '0 2px 8px rgba(226,0,116,0.04)' : 'none' }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16 }}>
|
||||
@@ -543,6 +553,6 @@ export const DocsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Content>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTheme } from '../../contexts';
|
||||
import { Content } from '../../components/layout/Content';
|
||||
import { TPattern } from '../../components/common/TPattern';
|
||||
import {
|
||||
listEvents,
|
||||
@@ -53,7 +52,10 @@ export const PerceptionPage: React.FC = () => {
|
||||
}
|
||||
}, [filterSource, filterImpact]);
|
||||
|
||||
useEffect(() => { void loadFeed(); }, [loadFeed]);
|
||||
useEffect(() => {
|
||||
const timerId = window.setTimeout(() => { void loadFeed(); }, 0);
|
||||
return () => window.clearTimeout(timerId);
|
||||
}, [loadFeed]);
|
||||
|
||||
// When selecting a new event, clear previous analysis
|
||||
const handleSelectEvent = (id: string) => {
|
||||
@@ -96,7 +98,7 @@ export const PerceptionPage: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Content wide>
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
<style>{`
|
||||
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0} }
|
||||
`}</style>
|
||||
@@ -113,7 +115,7 @@ export const PerceptionPage: React.FC = () => {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '400px 1fr',
|
||||
gap: 24,
|
||||
height: 'calc(100vh - 220px)',
|
||||
flex: 1,
|
||||
minHeight: 560,
|
||||
}}>
|
||||
{/* Left: Event feed */}
|
||||
@@ -139,6 +141,6 @@ export const PerceptionPage: React.FC = () => {
|
||||
onAbort={handleAbort}
|
||||
/>
|
||||
</div>
|
||||
</Content>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -133,7 +133,6 @@ export const RagChatPage: React.FC = () => {
|
||||
sessionId,
|
||||
abortRef.current.signal,
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filterRegulationType, sessionId]);
|
||||
|
||||
const sendMessage = (text: string) => {
|
||||
@@ -173,7 +172,7 @@ export const RagChatPage: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, display: 'flex', height: 'calc(100vh - 128px)' }}>
|
||||
<div style={{ flex: 1, display: 'flex', minHeight: 0, height: '100%' }}>
|
||||
{/* ── Left: chat panel ─────────────────────────────────── */}
|
||||
<div style={{
|
||||
flex: '0 0 60%',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useTheme } from '../../contexts';
|
||||
import { Content } from '../../components/layout/Content';
|
||||
import { TPattern } from '../../components/common/TPattern';
|
||||
import { getSystemStats, getSystemConfig, getSystemHealth, type SystemStats, type SystemConfig, type SystemHealth } from '../../api/status';
|
||||
import { getDocumentList, type DocInfo } from '../../api/docs';
|
||||
@@ -107,7 +106,8 @@ export const StatusPage: React.FC = () => {
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
const timerId = window.setTimeout(() => { void loadData(); }, 0);
|
||||
return () => window.clearTimeout(timerId);
|
||||
}, [loadData]);
|
||||
|
||||
// Auto-poll every 5 s while any document is still processing
|
||||
@@ -119,7 +119,7 @@ export const StatusPage: React.FC = () => {
|
||||
}, [docs, loadData]);
|
||||
|
||||
return (
|
||||
<Content>
|
||||
<div className="relative w-full">
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
<TPattern />
|
||||
|
||||
@@ -386,6 +386,6 @@ export const StatusPage: React.FC = () => {
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</Content>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
20
frontend/src/router/AppRouter.tsx
Normal file
20
frontend/src/router/AppRouter.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { AppShell } from '../components/layout/AppShell';
|
||||
import { appTabs, defaultTab } from './tabs';
|
||||
|
||||
export function AppRouter() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
<Route index element={<Navigate to={defaultTab.path} replace />} />
|
||||
{appTabs.map((tab) => (
|
||||
<Route key={tab.id} path={tab.path.slice(1)} element={null} />
|
||||
))}
|
||||
<Route path="*" element={<Navigate to={defaultTab.path} replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
73
frontend/src/router/tabs.tsx
Normal file
73
frontend/src/router/tabs.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
import { CompliancePage } from '../pages/Compliance';
|
||||
import { DocsPage } from '../pages/Docs';
|
||||
import { PerceptionPage } from '../pages/Perception';
|
||||
import { RagChatPage } from '../pages/RagChat';
|
||||
import { StatusPage } from '../pages/Status';
|
||||
|
||||
export type TabId = 'perception' | 'docs' | 'compliance' | 'status' | 'rag';
|
||||
|
||||
export type ContentWidth = 'default' | 'wide' | 'full';
|
||||
|
||||
export interface AppTabConfig {
|
||||
id: TabId;
|
||||
path: string;
|
||||
label: string;
|
||||
component: ComponentType;
|
||||
keepAlive: boolean;
|
||||
contentWidth: ContentWidth;
|
||||
fillHeight?: boolean;
|
||||
}
|
||||
|
||||
export const appTabs: AppTabConfig[] = [
|
||||
{
|
||||
id: 'perception',
|
||||
path: '/perception',
|
||||
label: '智能感知',
|
||||
component: PerceptionPage,
|
||||
keepAlive: true,
|
||||
contentWidth: 'wide',
|
||||
fillHeight: true,
|
||||
},
|
||||
{
|
||||
id: 'docs',
|
||||
path: '/docs',
|
||||
label: '文档管理',
|
||||
component: DocsPage,
|
||||
keepAlive: true,
|
||||
contentWidth: 'default',
|
||||
},
|
||||
{
|
||||
id: 'compliance',
|
||||
path: '/compliance',
|
||||
label: '合规分析',
|
||||
component: CompliancePage,
|
||||
keepAlive: true,
|
||||
contentWidth: 'full',
|
||||
fillHeight: true,
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
path: '/status',
|
||||
label: '系统状态',
|
||||
component: StatusPage,
|
||||
keepAlive: true,
|
||||
contentWidth: 'default',
|
||||
},
|
||||
{
|
||||
id: 'rag',
|
||||
path: '/rag',
|
||||
label: '法规对话',
|
||||
component: RagChatPage,
|
||||
keepAlive: true,
|
||||
contentWidth: 'full',
|
||||
fillHeight: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const defaultTab = appTabs.find((tab) => tab.id === 'compliance') ?? appTabs[0];
|
||||
|
||||
export function getTabByPath(pathname: string): AppTabConfig {
|
||||
return appTabs.find((tab) => tab.path === pathname) ?? defaultTab;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=TeleNeo:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||
@import "tw-animate-css";
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Light mode (default) */
|
||||
:root {
|
||||
@@ -19,6 +20,38 @@
|
||||
--t-orange: #ff7700;
|
||||
--t-accent-glow: rgba(226,0,116,0.08);
|
||||
--t-pattern-opacity: 0.04;
|
||||
--background: var(--t-bg);
|
||||
--foreground: var(--t-text);
|
||||
--card: var(--t-bg-card);
|
||||
--card-foreground: var(--t-text);
|
||||
--popover: var(--t-bg-card);
|
||||
--popover-foreground: var(--t-text);
|
||||
--primary: #e20074;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: var(--t-bg-hover);
|
||||
--secondary-foreground: var(--t-text);
|
||||
--muted: var(--t-bg-hover);
|
||||
--muted-foreground: var(--t-text3);
|
||||
--accent: rgba(226, 0, 116, 0.08);
|
||||
--accent-foreground: #e20074;
|
||||
--destructive: #ff4444;
|
||||
--border: var(--t-border);
|
||||
--input: var(--t-border);
|
||||
--ring: rgba(226, 0, 116, 0.35);
|
||||
--chart-1: #e20074;
|
||||
--chart-2: #be0060;
|
||||
--chart-3: #00b89c;
|
||||
--chart-4: #ff7700;
|
||||
--chart-5: #4a4a5a;
|
||||
--radius: 0.625rem;
|
||||
--sidebar: var(--t-bg-card);
|
||||
--sidebar-foreground: var(--t-text);
|
||||
--sidebar-primary: #e20074;
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: var(--t-bg-hover);
|
||||
--sidebar-accent-foreground: var(--t-text);
|
||||
--sidebar-border: var(--t-border);
|
||||
--sidebar-ring: rgba(226, 0, 116, 0.35);
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@@ -36,6 +69,37 @@
|
||||
--t-orange: #ff8800;
|
||||
--t-accent-glow: rgba(226,0,116,0.12);
|
||||
--t-pattern-opacity: 0.03;
|
||||
--background: var(--t-bg);
|
||||
--foreground: var(--t-text);
|
||||
--card: var(--t-bg-card);
|
||||
--card-foreground: var(--t-text);
|
||||
--popover: var(--t-bg-card);
|
||||
--popover-foreground: var(--t-text);
|
||||
--primary: #e20074;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: var(--t-bg-hover);
|
||||
--secondary-foreground: var(--t-text);
|
||||
--muted: var(--t-bg-hover);
|
||||
--muted-foreground: var(--t-text3);
|
||||
--accent: rgba(226, 0, 116, 0.14);
|
||||
--accent-foreground: #ff7abf;
|
||||
--destructive: #ff4444;
|
||||
--border: var(--t-border);
|
||||
--input: var(--t-border-light);
|
||||
--ring: rgba(226, 0, 116, 0.45);
|
||||
--chart-1: #e20074;
|
||||
--chart-2: #f04090;
|
||||
--chart-3: #00d4aa;
|
||||
--chart-4: #ff8800;
|
||||
--chart-5: #c0c0d0;
|
||||
--sidebar: var(--t-bg-card);
|
||||
--sidebar-foreground: var(--t-text);
|
||||
--sidebar-primary: #e20074;
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: var(--t-bg-hover);
|
||||
--sidebar-accent-foreground: var(--t-text);
|
||||
--sidebar-border: var(--t-border);
|
||||
--sidebar-ring: rgba(226, 0, 116, 0.45);
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
@@ -100,6 +164,11 @@ button, input {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Shell navigation manages its own transition timing. */
|
||||
[data-shell-tab='true'] {
|
||||
transition: color 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* T-Systems Button Style */
|
||||
.t-btn,
|
||||
.t-btn:hover {
|
||||
@@ -272,3 +341,59 @@ button, input {
|
||||
background: linear-gradient(135deg, #f0208a 0%, #d01070 100%);
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-heading: 'TeleNeo', 'Segoe UI', system-ui, sans-serif;
|
||||
--font-sans: 'TeleNeo', 'Segoe UI', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
@@ -3,5 +3,12 @@
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import path from 'node:path'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
@@ -11,6 +12,11 @@ export default defineConfig(({ mode }) => {
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: Number(env.FRONTEND_PORT || 5173),
|
||||
|
||||
11
skills-lock.json
Normal file
11
skills-lock.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"shadcn": {
|
||||
"source": "shadcn/ui",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/shadcn/SKILL.md",
|
||||
"computedHash": "f0fdd03f4755cbb3b57e20fe6fd4a97c00d34e6780187b6559316986e8d848db"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user